feat: new task-tui app
This commit is contained in:
parent
5f99fb7b25
commit
fabaf8aa9f
4 changed files with 819 additions and 286 deletions
17
ayo.sh
17
ayo.sh
|
|
@ -15,19 +15,6 @@ case $1 in
|
|||
. ${scripts_dir}/journal.sh -t "$@"
|
||||
;;
|
||||
|
||||
tl) # Tasks list
|
||||
. ${scripts_dir}/tasks.sh list "$@"
|
||||
;;
|
||||
to) # Tasks open
|
||||
. ${scripts_dir}/tasks.sh open "$@"
|
||||
;;
|
||||
tr) # Tasks remove
|
||||
. ${scripts_dir}/tasks.sh remove "$@"
|
||||
;;
|
||||
td) # Tasks mark as done
|
||||
. ${scripts_dir}/tasks.sh done "$@"
|
||||
;;
|
||||
|
||||
nd) # Notes diff
|
||||
. ${scripts_dir}/notes.sh diff
|
||||
;;
|
||||
|
|
@ -91,10 +78,10 @@ case $1 in
|
|||
. ${scripts_dir}/git.sh "$@"
|
||||
;;
|
||||
n | notes)
|
||||
. ${scripts_dir}/notes.sh "$@"
|
||||
. ${scripts_dir}/notes.sh $@
|
||||
;;
|
||||
t | tasks)
|
||||
. ${scripts_dir}/tasks.sh "$@"
|
||||
TASKS_HOME=${TASKS_HOME} /${scripts_dir}/task-tui "${@:2}"
|
||||
;;
|
||||
j | journal)
|
||||
. ${scripts_dir}/journal.sh "$@"
|
||||
|
|
|
|||
|
|
@ -21,3 +21,6 @@ agent_modelfile="${HOME}/path-to/Modelfile-agent"
|
|||
secondary=HDMI-1
|
||||
main=HDMI-0
|
||||
internal=eDP-1-1
|
||||
|
||||
# tasks
|
||||
TASK_HOME="%{HOME}/Notes/Tasks"
|
||||
|
|
|
|||
814
task-tui
Executable file
814
task-tui
Executable file
|
|
@ -0,0 +1,814 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
task-tui — a terminal UI for managing task files across two stores.
|
||||
|
||||
TWO STORES (split by owner)
|
||||
• "mine" — Ayo's own tasks (owner: Ayo). Lives OUTSIDE ~/Company, in the Notes so
|
||||
it syncs across devices: $TASKS_HOME (default ~/inputs/Notes/Tasks).
|
||||
This is the DEFAULT store — the one Ayo works in via the TUI.
|
||||
• "agents" — the agency's own work items (owner: Hermes | Claude). Lives in
|
||||
$COMPANY_ROOT/operations/tasks. Reached with --agents (a rare view for
|
||||
Ayo). The redaction gate ( sanitized-by ) only ever applies here.
|
||||
|
||||
Both stores use the same layout: status is the FOLDER a task lives in
|
||||
(backlog -> next -> in-progress -> done); priority/owner/context live in frontmatter;
|
||||
the FILENAME date is the *last-updated* date; the `id:` is stable and never changes.
|
||||
This tool enforces those rules so they can't drift — it re-dates filenames on every
|
||||
mutation, stamps the ## Log, uses `git mv` (in whichever repo the file lives), and
|
||||
never touches `id:`.
|
||||
|
||||
WHO RUNS IT
|
||||
A human (Ayo) or Hermes (Internal), locally — like find-task/company it reads files on
|
||||
disk; nothing leaves the machine. It is NOT run by the Outsourced agent (Claude), whose
|
||||
context leaves the machine and which has zero access to the Notes ("mine" store).
|
||||
|
||||
The redaction gate is respected, not bypassed: this tool will NOT stamp
|
||||
`sanitized-by`/`sanitized-on` (only Hermes may, after redacting). In the agents' store
|
||||
an owner: Claude task without that attestation is flagged ⚠ un-attested , mirroring
|
||||
find-task's refusal to surface it to the cloud. The "mine" store holds no Claude tasks.
|
||||
|
||||
USAGE
|
||||
task-tui Launch the TUI on your store (Notes/Tasks)
|
||||
task-tui --agents Launch the TUI on the agents' store (operations/tasks)
|
||||
task-tui list [--status S] [--owner O] Print a table (headless)
|
||||
task-tui show <id|file> Print one task
|
||||
task-tui new --title T [--priority P2] [--owner Ayo] [--context @admin]
|
||||
[--project X] [--status backlog] [--goal ...] [--next ...]
|
||||
[--notes ...] [--links ...]
|
||||
task-tui move <id|file> <status> Move between status folders (git mv)
|
||||
task-tui selftest Exercise the core logic on temp stores; print OK
|
||||
task-tui help
|
||||
|
||||
Any subcommand takes --agents (or --store agents|mine) to pick the store; default mine.
|
||||
Env: TASKS_HOME = your store root (default ~/inputs/Notes/Tasks)
|
||||
COMPANY_ROOT = Company root (default ~/Company) — parent of the agents' store
|
||||
"""
|
||||
|
||||
import curses
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config / format constants (kept in sync with operations/README.md task format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATUSES = ["backlog", "next", "in-progress", "done"]
|
||||
PRIORITIES = ["P0", "P1", "P2", "P3"]
|
||||
OWNERS = ["Ayo", "Hermes", "Claude"]
|
||||
CONTEXTS = ["@code", "@calls", "@errand", "@home", "@admin", "@waiting"]
|
||||
|
||||
# Canonical frontmatter field order (matches the template in operations/README.md).
|
||||
FIELD_ORDER = [
|
||||
"id", "title", "priority", "owner", "sanitized-by", "sanitized-on",
|
||||
"context", "project", "created", "links",
|
||||
]
|
||||
|
||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
||||
|
||||
# Which store the process is operating on ("mine" default, "agents" via --agents).
|
||||
_STORE = "mine"
|
||||
|
||||
|
||||
def company_root():
|
||||
root = os.environ.get("COMPANY_ROOT") or os.path.expanduser("~/Company")
|
||||
if not os.path.isdir(root) and os.path.isdir("/home/claude/Company"):
|
||||
root = "/home/claude/Company"
|
||||
return root
|
||||
|
||||
|
||||
def mine_root():
|
||||
return os.environ.get("TASKS_HOME") or os.path.expanduser("~/inputs/Notes/Tasks")
|
||||
|
||||
|
||||
def agents_root():
|
||||
return os.path.join(company_root(), "operations", "tasks")
|
||||
|
||||
|
||||
def set_store(name):
|
||||
global _STORE
|
||||
if name not in ("mine", "agents"):
|
||||
raise ValueError(f"unknown store: {name}")
|
||||
_STORE = name
|
||||
|
||||
|
||||
def store_label():
|
||||
return "yours · Notes/Tasks" if _STORE == "mine" else "agents · operations/tasks"
|
||||
|
||||
|
||||
def tasks_dir():
|
||||
return mine_root() if _STORE == "mine" else agents_root()
|
||||
|
||||
|
||||
def today():
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for s in STATUSES:
|
||||
os.makedirs(os.path.join(tasks_dir(), s), exist_ok=True)
|
||||
|
||||
|
||||
def slugify(title):
|
||||
s = re.sub(r"[^a-z0-9]+", "-", title.strip().lower()).strip("-")
|
||||
return s or "task"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task model — parse / serialize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Task:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.status = os.path.basename(os.path.dirname(path))
|
||||
self.fields = {} # frontmatter key -> value (strings)
|
||||
self.body = "" # everything after the frontmatter block
|
||||
self._parse()
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return os.path.basename(self.path)
|
||||
|
||||
@property
|
||||
def slug(self):
|
||||
name = self.filename[:-3] if self.filename.endswith(".md") else self.filename
|
||||
return DATE_RE.sub("", name)
|
||||
|
||||
def _parse(self):
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
except OSError:
|
||||
return
|
||||
if text.startswith("---"):
|
||||
parts = text.split("\n")
|
||||
end = None
|
||||
for i in range(1, len(parts)):
|
||||
if parts[i].strip() == "---":
|
||||
end = i
|
||||
break
|
||||
if end is not None:
|
||||
for line in parts[1:end]:
|
||||
m = re.match(r"^([A-Za-z0-9_-]+):\s?(.*)$", line)
|
||||
if m:
|
||||
self.fields[m.group(1)] = m.group(2).strip()
|
||||
self.body = "\n".join(parts[end + 1:]).lstrip("\n")
|
||||
return
|
||||
self.body = text
|
||||
|
||||
# --- convenience accessors -------------------------------------------
|
||||
def get(self, key, default=""):
|
||||
return self.fields.get(key, default)
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.get("title") or "(no title)"
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return self.get("priority") or "?"
|
||||
|
||||
@property
|
||||
def owner(self):
|
||||
return self.get("owner") or "-"
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
return self.get("context") or "-"
|
||||
|
||||
@property
|
||||
def unattested(self):
|
||||
"""owner: Claude with no sanitized-by attestation (redaction gate)."""
|
||||
return self.owner == "Claude" and not self.get("sanitized-by")
|
||||
|
||||
def prio_rank(self):
|
||||
try:
|
||||
return PRIORITIES.index(self.priority)
|
||||
except ValueError:
|
||||
return len(PRIORITIES)
|
||||
|
||||
# --- serialization ----------------------------------------------------
|
||||
def serialize(self):
|
||||
lines = ["---"]
|
||||
for key in FIELD_ORDER:
|
||||
lines.append(f"{key}: {self.fields.get(key, '')}".rstrip())
|
||||
for key, val in self.fields.items(): # preserve any extra keys
|
||||
if key not in FIELD_ORDER:
|
||||
lines.append(f"{key}: {val}".rstrip())
|
||||
lines.append("---")
|
||||
body = self.body.rstrip("\n")
|
||||
return "\n".join(lines) + ("\n\n" + body if body else "") + "\n"
|
||||
|
||||
def save(self):
|
||||
with open(self.path, "w", encoding="utf-8") as fh:
|
||||
fh.write(self.serialize())
|
||||
|
||||
# --- mutations (each re-dates the filename to today) ------------------
|
||||
def _target_name(self, when=None):
|
||||
return f"{when or today()}-{self.slug}.md"
|
||||
|
||||
def redate(self):
|
||||
"""Rename the file so its date prefix == today (the last-updated rule)."""
|
||||
new_name = self._target_name()
|
||||
if new_name == self.filename:
|
||||
return
|
||||
newpath = _unique_path(os.path.join(os.path.dirname(self.path), new_name),
|
||||
keep=self.path)
|
||||
_rename(self.path, newpath)
|
||||
self.path = newpath
|
||||
|
||||
def set_field(self, key, value):
|
||||
self.fields[key] = value
|
||||
self.save()
|
||||
self.redate()
|
||||
|
||||
def append_log(self, msg):
|
||||
entry = f"- {today()} — {msg}"
|
||||
if re.search(r"(?m)^##\s+Log\s*$", self.body):
|
||||
self.body = self.body.rstrip("\n") + "\n" + entry
|
||||
else:
|
||||
self.body = self.body.rstrip("\n") + f"\n\n## Log\n{entry}"
|
||||
self.save()
|
||||
self.redate()
|
||||
|
||||
def move_to(self, new_status):
|
||||
if new_status not in STATUSES:
|
||||
raise ValueError(f"unknown status: {new_status}")
|
||||
if new_status == self.status:
|
||||
return
|
||||
dest_dir = os.path.join(tasks_dir(), new_status)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
newpath = _unique_path(os.path.join(dest_dir, self._target_name()))
|
||||
_rename(self.path, newpath)
|
||||
self.path = newpath
|
||||
self.status = new_status
|
||||
self.append_log(f"moved to {new_status}.")
|
||||
|
||||
|
||||
def _unique_path(path, keep=None):
|
||||
"""Avoid clobbering an existing file; `keep` is the path allowed to match."""
|
||||
if not os.path.exists(path) or path == keep:
|
||||
return path
|
||||
base, ext = os.path.splitext(path)
|
||||
i = 2
|
||||
while os.path.exists(f"{base}-{i}{ext}"):
|
||||
i += 1
|
||||
return f"{base}-{i}{ext}"
|
||||
|
||||
|
||||
def _git_root(path):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", os.path.dirname(path), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, check=True)
|
||||
return out.stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def _rename(old, new):
|
||||
"""Prefer `git mv` in whichever repo the file lives; else a plain rename."""
|
||||
if old == new:
|
||||
return
|
||||
root = _git_root(old)
|
||||
if root:
|
||||
try:
|
||||
subprocess.run(["git", "-C", root, "mv", old, new],
|
||||
check=True, capture_output=True)
|
||||
return
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass # untracked file — fall through to a plain rename
|
||||
os.rename(old, new)
|
||||
|
||||
|
||||
def load_all():
|
||||
ensure_dirs()
|
||||
tasks = []
|
||||
for s in STATUSES:
|
||||
d = os.path.join(tasks_dir(), s)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
for name in sorted(os.listdir(d)):
|
||||
if name.endswith(".md") and not name.startswith("_"):
|
||||
tasks.append(Task(os.path.join(d, name)))
|
||||
return tasks
|
||||
|
||||
|
||||
def find_one(ref):
|
||||
"""Resolve a task by `id:`, filename, slug, or path (within the current store)."""
|
||||
if os.path.isfile(ref):
|
||||
return Task(ref)
|
||||
for t in load_all():
|
||||
if t.get("id") == ref or t.filename == ref or t.slug == ref:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def create_task(title, priority="P2", owner="Ayo", context="@admin",
|
||||
project="", status="backlog", goal="", next_action="",
|
||||
notes="", links=""):
|
||||
ensure_dirs()
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"unknown status: {status}")
|
||||
slug = slugify(title)
|
||||
tid = f"{today()}-{slug}"
|
||||
path = _unique_path(os.path.join(tasks_dir(), status, f"{today()}-{slug}.md"))
|
||||
t = Task.__new__(Task)
|
||||
t.path = path
|
||||
t.status = status
|
||||
t.fields = {
|
||||
"id": tid, "title": title, "priority": priority, "owner": owner,
|
||||
"sanitized-by": "", "sanitized-on": "", "context": context,
|
||||
"project": project, "created": today(), "links": links or "[]",
|
||||
}
|
||||
t.body = (
|
||||
"## Goal\n" + (goal or "What done looks like, in one or two sentences.") + "\n\n"
|
||||
"## Next action\n" + (next_action or "The single concrete next step — a physical verb.") + "\n\n"
|
||||
"## Notes\n" + (notes or "Context, sub-steps, blockers.") + "\n\n"
|
||||
"## Log\n" + f"- {today()} — created.\n"
|
||||
)
|
||||
t.save()
|
||||
return t
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Headless CLI (scriptable + testable without a TTY)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cli_list(argv):
|
||||
status = owner = None
|
||||
it = iter(argv)
|
||||
for a in it:
|
||||
if a == "--status":
|
||||
status = next(it, None)
|
||||
elif a == "--owner":
|
||||
owner = next(it, None)
|
||||
tasks = [t for t in load_all()
|
||||
if (not status or t.status == status) and (not owner or t.owner == owner)]
|
||||
tasks.sort(key=lambda t: (STATUSES.index(t.status), t.prio_rank()))
|
||||
print(f"[store: {store_label()}]")
|
||||
print(f"{'PRI':3} {'OWNER':6} {'STATUS':11} {'CONTEXT':9} TASK")
|
||||
for t in tasks:
|
||||
flag = " ⚠ un-attested" if t.unattested else ""
|
||||
print(f"{t.priority:3} {t.owner:6} {t.status:11} {t.context:9} {t.title}{flag}")
|
||||
print(f" {t.path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cli_show(argv):
|
||||
if not argv:
|
||||
print("usage: task-tui show <id|file>", file=sys.stderr)
|
||||
return 2
|
||||
t = find_one(argv[0])
|
||||
if not t:
|
||||
print(f"not found: {argv[0]}", file=sys.stderr)
|
||||
return 1
|
||||
print(t.serialize())
|
||||
return 0
|
||||
|
||||
|
||||
def cli_new(argv):
|
||||
kw, it = {}, iter(argv)
|
||||
keymap = {"--title": "title", "--priority": "priority", "--owner": "owner",
|
||||
"--context": "context", "--project": "project", "--status": "status",
|
||||
"--goal": "goal", "--next": "next_action", "--notes": "notes",
|
||||
"--links": "links"}
|
||||
for a in it:
|
||||
if a in keymap:
|
||||
kw[keymap[a]] = next(it, "")
|
||||
if not kw.get("title"):
|
||||
print("usage: task-tui new --title T [--priority P2] [--owner Ayo] ...",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
t = create_task(**kw)
|
||||
print(f"created ({store_label()}): {t.path}")
|
||||
if t.unattested:
|
||||
print(" ⚠ owner: Claude with no sanitized-by — Hermes must redact & attest "
|
||||
"before this can be surfaced to the cloud.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def cli_move(argv):
|
||||
if len(argv) < 2:
|
||||
print("usage: task-tui move <id|file> <status>", file=sys.stderr)
|
||||
return 2
|
||||
t = find_one(argv[0])
|
||||
if not t:
|
||||
print(f"not found: {argv[0]}", file=sys.stderr)
|
||||
return 1
|
||||
if argv[1] not in STATUSES:
|
||||
print(f"unknown status: {argv[1]} (choose from {', '.join(STATUSES)})",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
t.move_to(argv[1])
|
||||
print(f"moved -> {t.path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cli_selftest(argv):
|
||||
import tempfile
|
||||
base = tempfile.mkdtemp(prefix="task-tui-selftest-")
|
||||
os.environ["TASKS_HOME"] = os.path.join(base, "notes-tasks")
|
||||
os.environ["COMPANY_ROOT"] = os.path.join(base, "company")
|
||||
# Create both roots up front so company_root()'s "fall back to the real ~/Company
|
||||
# if the configured root is missing" guard never fires during the test.
|
||||
os.makedirs(os.environ["TASKS_HOME"], exist_ok=True)
|
||||
os.makedirs(os.path.join(os.environ["COMPANY_ROOT"], "operations", "tasks"), exist_ok=True)
|
||||
subprocess.run(["git", "-C", base, "init", "-q"], check=False, capture_output=True)
|
||||
try:
|
||||
# --- "mine" store (default): Ayo's task ---
|
||||
set_store("mine")
|
||||
t = create_task("Book the blood-sugar appointment", priority="P1", owner="Ayo",
|
||||
context="@calls", status="backlog", goal="Appt booked",
|
||||
next_action="Call the clinic")
|
||||
assert t.get("id").endswith("-book-the-blood-sugar-appointment"), t.get("id")
|
||||
assert t.path.startswith(os.environ["TASKS_HOME"]), t.path
|
||||
original_id = t.get("id")
|
||||
|
||||
t.set_field("priority", "P0") # update field -> re-date, id kept
|
||||
assert t.priority == "P0" and t.get("id") == original_id
|
||||
assert t.filename.startswith(today()), t.filename
|
||||
|
||||
t.append_log("bumped to P0.")
|
||||
assert "bumped to P0." in Task(t.path).body
|
||||
|
||||
t.move_to("in-progress") # move -> folder + log stamp
|
||||
assert t.status == "in-progress"
|
||||
assert "moved to in-progress." in Task(t.path).body
|
||||
assert len(load_all()) == 1
|
||||
|
||||
# --- "agents" store: redaction gate still applies ---
|
||||
set_store("agents")
|
||||
assert len(load_all()) == 0, "stores must be independent"
|
||||
c = create_task("Sanitize inbox rule", owner="Claude", status="backlog")
|
||||
assert c.unattested, "owner:Claude without sanitized-by must be un-attested"
|
||||
assert c.path.startswith(os.environ["COMPANY_ROOT"]), c.path
|
||||
|
||||
set_store("mine") # mine store unaffected by agents' add
|
||||
assert len(load_all()) == 1
|
||||
print("selftest: OK (two stores · create/redate/log/move/redaction-gate pass)")
|
||||
return 0
|
||||
except AssertionError as e:
|
||||
print(f"selftest: FAIL — {e}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# curses TUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HELP_TEXT = [
|
||||
"task-tui — keys",
|
||||
"",
|
||||
" j / ↓ down k / ↑ up",
|
||||
" Tab cycle status filter (all → each folder)",
|
||||
" s cycle sort (status+priority / priority / updated)",
|
||||
" S switch store (yours ↔ agents) — agents view is the rare case",
|
||||
" Enter view full task",
|
||||
" n new task",
|
||||
" e edit in $EDITOR (re-dates filename, logs 'edited')",
|
||||
" m move to another status folder",
|
||||
" p cycle priority o set owner c set context",
|
||||
" l add a log entry",
|
||||
" r reload from disk",
|
||||
" ? this help q quit",
|
||||
"",
|
||||
" Status = folder · filename date = last-updated · id never changes.",
|
||||
" ⚠ = owner:Claude with no sanitized-by (agents store; Hermes must redact/attest).",
|
||||
"",
|
||||
" (press any key)",
|
||||
]
|
||||
|
||||
|
||||
class TUI:
|
||||
def __init__(self, stdscr):
|
||||
self.scr = stdscr
|
||||
self.tasks = []
|
||||
self.sel = 0
|
||||
self.top = 0
|
||||
self.filter_status = None
|
||||
self.sort_mode = 0
|
||||
self.msg = ""
|
||||
self.reload()
|
||||
|
||||
# --- data ------------------------------------------------------------
|
||||
def reload(self):
|
||||
self.tasks = load_all()
|
||||
self._sort()
|
||||
self.sel = min(self.sel, max(0, len(self.visible()) - 1))
|
||||
|
||||
def _sort(self):
|
||||
if self.sort_mode == 1:
|
||||
self.tasks.sort(key=lambda t: (t.prio_rank(), t.title.lower()))
|
||||
elif self.sort_mode == 2:
|
||||
self.tasks.sort(key=lambda t: t.filename, reverse=True)
|
||||
else:
|
||||
self.tasks.sort(key=lambda t: (STATUSES.index(t.status), t.prio_rank()))
|
||||
|
||||
def visible(self):
|
||||
if self.filter_status is None:
|
||||
return self.tasks
|
||||
return [t for t in self.tasks if t.status == self.filter_status]
|
||||
|
||||
def current(self):
|
||||
vis = self.visible()
|
||||
return vis[self.sel] if vis and 0 <= self.sel < len(vis) else None
|
||||
|
||||
# --- drawing ---------------------------------------------------------
|
||||
def draw(self):
|
||||
self.scr.erase()
|
||||
h, w = self.scr.getmaxyx()
|
||||
vis = self.visible()
|
||||
flt = self.filter_status or "all"
|
||||
sortname = ["status+prio", "priority", "updated"][self.sort_mode]
|
||||
header = f" task-tui [{store_label()}] filter:{flt} sort:{sortname} {len(vis)} tasks "
|
||||
self.scr.addnstr(0, 0, header.ljust(w), w - 1, curses.A_REVERSE)
|
||||
|
||||
list_h = max(3, (h - 4) * 3 // 5)
|
||||
if self.sel < self.top:
|
||||
self.top = self.sel
|
||||
elif self.sel >= self.top + list_h:
|
||||
self.top = self.sel - list_h + 1
|
||||
|
||||
row = 1
|
||||
for i in range(self.top, min(len(vis), self.top + list_h)):
|
||||
t = vis[i]
|
||||
flag = "⚠" if t.unattested else " "
|
||||
line = f"{flag} {t.priority:2} {t.owner:6} {t.status:11} {t.context:8} {t.title}"
|
||||
attr = curses.A_REVERSE if i == self.sel else curses.A_NORMAL
|
||||
self.scr.addnstr(row, 0, line.ljust(w - 1), w - 1, attr)
|
||||
row += 1
|
||||
|
||||
div = list_h + 1
|
||||
if div < h - 1:
|
||||
self.scr.addnstr(div, 0, "─" * (w - 1), w - 1)
|
||||
self._draw_preview(div + 1, h - 2, w)
|
||||
|
||||
footer = self.msg or " n:new e:edit m:move p:prio o:owner c:ctx l:log S:store Enter:view ?:help q:quit "
|
||||
self.scr.addnstr(h - 1, 0, footer.ljust(w - 1), w - 1, curses.A_REVERSE)
|
||||
self.scr.refresh()
|
||||
|
||||
def _draw_preview(self, y0, y1, w):
|
||||
t = self.current()
|
||||
if not t:
|
||||
self.scr.addnstr(y0, 0, " (no task selected)", w - 1)
|
||||
return
|
||||
lines = [f" {t.filename}", f" id: {t.get('id')} created: {t.get('created')}"]
|
||||
if t.unattested:
|
||||
lines.append(" ⚠ owner:Claude — no sanitized-by attestation (Hermes must redact)")
|
||||
lines.append("")
|
||||
for bl in t.body.splitlines():
|
||||
if bl.strip():
|
||||
lines.extend(" " + s for s in textwrap.wrap(bl, w - 4))
|
||||
else:
|
||||
lines.append("")
|
||||
y = y0
|
||||
for ln in lines:
|
||||
if y >= y1:
|
||||
break
|
||||
self.scr.addnstr(y, 0, ln, w - 1)
|
||||
y += 1
|
||||
|
||||
# --- input helpers ---------------------------------------------------
|
||||
def prompt(self, label, default=""):
|
||||
h, w = self.scr.getmaxyx()
|
||||
curses.echo()
|
||||
curses.curs_set(1)
|
||||
self.scr.addnstr(h - 1, 0, (" " + label + " ").ljust(w - 1), w - 1, curses.A_REVERSE)
|
||||
try:
|
||||
raw = self.scr.getstr(h - 1, len(label) + 2, w - len(label) - 4)
|
||||
val = raw.decode("utf-8").strip()
|
||||
except Exception:
|
||||
val = ""
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
return val or default
|
||||
|
||||
def choose(self, label, options):
|
||||
"""Single-key chooser: shows options[0..], returns chosen or None."""
|
||||
h, w = self.scr.getmaxyx()
|
||||
opts = " ".join(f"{i+1}:{o}" for i, o in enumerate(options))
|
||||
self.scr.addnstr(h - 1, 0, f" {label} {opts} (Esc cancel) ".ljust(w - 1),
|
||||
w - 1, curses.A_REVERSE)
|
||||
self.scr.refresh()
|
||||
ch = self.scr.getch()
|
||||
if options and ord("1") <= ch <= ord("1") + min(9, len(options)) - 1:
|
||||
idx = ch - ord("1")
|
||||
if 0 <= idx < len(options):
|
||||
return options[idx]
|
||||
return None
|
||||
|
||||
def flash(self, msg):
|
||||
self.msg = " " + msg + " "
|
||||
|
||||
# --- actions ---------------------------------------------------------
|
||||
def act_new(self):
|
||||
title = self.prompt("New task title:")
|
||||
if not title:
|
||||
self.flash("cancelled")
|
||||
return
|
||||
pri = self.choose("Priority", PRIORITIES) or "P2"
|
||||
owner = self.choose("Owner", OWNERS) or ("Ayo" if _STORE == "mine" else "Hermes")
|
||||
ctx = self.choose("Context", CONTEXTS) or "@admin"
|
||||
status = self.choose("Status", STATUSES) or "backlog"
|
||||
goal = self.prompt("Goal (optional):")
|
||||
nxt = self.prompt("Next action (optional):")
|
||||
t = create_task(title, priority=pri, owner=owner, context=ctx,
|
||||
status=status, goal=goal, next_action=nxt)
|
||||
self.reload()
|
||||
self.flash(f"created {t.filename}"
|
||||
+ (" ⚠ needs Hermes redaction" if t.unattested else ""))
|
||||
|
||||
def act_edit(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
editor = os.environ.get("EDITOR") or shutil.which("nano") or shutil.which("vi") or "vi"
|
||||
curses.endwin()
|
||||
subprocess.call([editor, t.path])
|
||||
Task(t.path).append_log("edited.")
|
||||
self.scr = curses.initscr()
|
||||
curses.curs_set(0)
|
||||
self.reload()
|
||||
self.flash("saved & re-dated")
|
||||
|
||||
def act_move(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
dest = self.choose("Move to", STATUSES)
|
||||
if not dest:
|
||||
self.flash("cancelled")
|
||||
return
|
||||
t.move_to(dest)
|
||||
self.reload()
|
||||
self.flash(f"moved -> {dest}")
|
||||
|
||||
def act_field(self, key, options=None):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
val = self.choose(f"Set {key}", options) if options else self.prompt(f"Set {key}:", t.get(key))
|
||||
if val is None:
|
||||
return
|
||||
t.set_field(key, val)
|
||||
if key == "owner":
|
||||
t.append_log(f"owner set to {val}.")
|
||||
self.reload()
|
||||
self.flash(f"{key} = {val}")
|
||||
|
||||
def act_cycle_priority(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
try:
|
||||
nxt = PRIORITIES[(PRIORITIES.index(t.priority) + 1) % len(PRIORITIES)]
|
||||
except ValueError:
|
||||
nxt = "P2"
|
||||
t.set_field("priority", nxt)
|
||||
self.reload()
|
||||
self.flash(f"priority = {nxt}")
|
||||
|
||||
def act_log(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
msg = self.prompt("Log entry:")
|
||||
if msg:
|
||||
t.append_log(msg)
|
||||
self.reload()
|
||||
self.flash("logged")
|
||||
|
||||
def act_switch_store(self):
|
||||
set_store("agents" if _STORE == "mine" else "mine")
|
||||
self.sel = self.top = 0
|
||||
self.reload()
|
||||
self.flash(f"store: {store_label()}")
|
||||
|
||||
def view_full(self):
|
||||
t = self.current()
|
||||
if not t:
|
||||
return
|
||||
self.scr.erase()
|
||||
h, w = self.scr.getmaxyx()
|
||||
y = 0
|
||||
for ln in t.serialize().splitlines():
|
||||
if y >= h - 1:
|
||||
break
|
||||
self.scr.addnstr(y, 0, ln[:w - 1], w - 1)
|
||||
y += 1
|
||||
self.scr.addnstr(h - 1, 0, " (press any key) ".ljust(w - 1), w - 1, curses.A_REVERSE)
|
||||
self.scr.getch()
|
||||
|
||||
def help(self):
|
||||
self.scr.erase()
|
||||
h, w = self.scr.getmaxyx()
|
||||
for i, ln in enumerate(HELP_TEXT):
|
||||
if i >= h - 1:
|
||||
break
|
||||
self.scr.addnstr(i, 2, ln, w - 3)
|
||||
self.scr.getch()
|
||||
|
||||
# --- main loop -------------------------------------------------------
|
||||
def run(self):
|
||||
curses.curs_set(0)
|
||||
while True:
|
||||
self.draw()
|
||||
self.msg = ""
|
||||
ch = self.scr.getch()
|
||||
vis = self.visible()
|
||||
if ch == ord("q"):
|
||||
break
|
||||
elif ch in (ord("j"), curses.KEY_DOWN):
|
||||
self.sel = min(self.sel + 1, max(0, len(vis) - 1))
|
||||
elif ch in (ord("k"), curses.KEY_UP):
|
||||
self.sel = max(self.sel - 1, 0)
|
||||
elif ch == ord("\t"):
|
||||
order = [None] + STATUSES
|
||||
self.filter_status = order[(order.index(self.filter_status) + 1) % len(order)]
|
||||
self.sel = 0
|
||||
elif ch == ord("s"):
|
||||
self.sort_mode = (self.sort_mode + 1) % 3
|
||||
self._sort()
|
||||
elif ch == ord("S"):
|
||||
self.act_switch_store()
|
||||
elif ch in (curses.KEY_ENTER, 10, 13):
|
||||
self.view_full()
|
||||
elif ch == ord("n"):
|
||||
self.act_new()
|
||||
elif ch == ord("e"):
|
||||
self.act_edit()
|
||||
elif ch == ord("m"):
|
||||
self.act_move()
|
||||
elif ch == ord("p"):
|
||||
self.act_cycle_priority()
|
||||
elif ch == ord("o"):
|
||||
self.act_field("owner", OWNERS)
|
||||
elif ch == ord("c"):
|
||||
self.act_field("context", CONTEXTS)
|
||||
elif ch == ord("l"):
|
||||
self.act_log()
|
||||
elif ch == ord("r"):
|
||||
self.reload()
|
||||
self.flash("reloaded")
|
||||
elif ch == ord("?"):
|
||||
self.help()
|
||||
|
||||
|
||||
def run_tui():
|
||||
if not sys.stdout.isatty():
|
||||
print("task-tui: not a TTY — use the headless subcommands "
|
||||
"(list/show/new/move) or run in a terminal.", file=sys.stderr)
|
||||
return 2
|
||||
curses.wrapper(lambda scr: TUI(scr).run())
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_store(argv):
|
||||
"""Pull a --agents / --store X flag out of argv; set the store; return the rest."""
|
||||
out, it = [], iter(argv)
|
||||
for a in it:
|
||||
if a == "--agents":
|
||||
set_store("agents")
|
||||
elif a == "--mine":
|
||||
set_store("mine")
|
||||
elif a == "--store":
|
||||
set_store(next(it, "mine"))
|
||||
else:
|
||||
out.append(a)
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
argv = _extract_store(argv)
|
||||
if not argv:
|
||||
return run_tui()
|
||||
cmd, rest = argv[0], argv[1:]
|
||||
dispatch = {
|
||||
"list": cli_list, "show": cli_show, "new": cli_new,
|
||||
"move": cli_move, "selftest": cli_selftest,
|
||||
}
|
||||
if cmd in dispatch:
|
||||
return dispatch[cmd](rest)
|
||||
if cmd in ("help", "-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
if cmd in ("tui", "ui"):
|
||||
return run_tui()
|
||||
print(f"task-tui: unknown command '{cmd}' (try 'help')", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
271
tasks.sh
271
tasks.sh
|
|
@ -1,271 +0,0 @@
|
|||
#! /usr/bin/bash
|
||||
|
||||
## tasks management
|
||||
|
||||
# Load config
|
||||
. ${HOME}/ayo.conf
|
||||
. ${scripts_dir}/functions.sh
|
||||
|
||||
# TODO: write log for echoes with >>>
|
||||
|
||||
command=$1
|
||||
|
||||
getopts "t" typora; #check if -t flag is given
|
||||
|
||||
function listTasks() {
|
||||
files=( $tasks_dir/*.md )
|
||||
index=0
|
||||
echo "TASKS: "
|
||||
for file in "${files[@]##*/}"; do
|
||||
((index++))
|
||||
echo "$index) $file"
|
||||
done
|
||||
}
|
||||
|
||||
function listProjects() {
|
||||
cd "$tasks_dir"
|
||||
dirs=( */ )
|
||||
index=0
|
||||
echo "PROJECTS: "
|
||||
for dir in "${dirs[@]}"; do
|
||||
((index++))
|
||||
echo "${index}) ${dir}"
|
||||
done
|
||||
}
|
||||
|
||||
function editFile() {
|
||||
notesSync
|
||||
|
||||
# Open in editor
|
||||
if [ "$typora" = "t" ]; then
|
||||
typora "$1"
|
||||
else
|
||||
vim "$1"
|
||||
fi
|
||||
|
||||
notesSync
|
||||
}
|
||||
|
||||
function createtask() {
|
||||
{
|
||||
read -p "Create new task: " title
|
||||
|
||||
if [ "$title" = "" ]; then
|
||||
echo "Title cannot be empty."
|
||||
exit;
|
||||
fi;
|
||||
|
||||
file_name=$title.md
|
||||
full_path="${tasks_dir}/${file_name}"
|
||||
|
||||
# IF Not Exists: create file & echo date
|
||||
if ! test -f "$full_path"; then
|
||||
install -Dv /dev/null "$full_path" >/dev/null
|
||||
# TODO: update to correct heading from old entries
|
||||
heading="# $title"
|
||||
echo $heading > "$full_path"
|
||||
date_heading=$(date +'%b %d, %Y, %a %r')
|
||||
echo $date_heading >> "$full_path"
|
||||
else
|
||||
editFile "$full_path"
|
||||
fi
|
||||
|
||||
|
||||
} || {
|
||||
echo ">>> New task failed"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## LIST tasks in directory
|
||||
if [ "$1" = "list" ] || [ "$1" = "l" ]; then
|
||||
notesSync
|
||||
listProjects
|
||||
listTasks
|
||||
|
||||
## OPEN a task from a list
|
||||
elif [ "$1" = "open" ] || [ "$1" = "o" ]; then
|
||||
notesSync
|
||||
files=( $tasks_dir/*.md )
|
||||
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
open_file=${files[$index]}
|
||||
editFile "$open_file"
|
||||
else
|
||||
PS3="Open file #: "
|
||||
echo "Please select a file to OPEN."
|
||||
select file in "${files[@]##*/}"; do
|
||||
{
|
||||
echo "Opening $file"
|
||||
editFile "${tasks_dir}/$file"
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## MARK AS DONE a task from a list
|
||||
elif [ "$1" = "done" ] || [ "$1" = "d" ]; then
|
||||
notesSync
|
||||
files=( $tasks_dir/*.md )
|
||||
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
done_file=${files[$index]}
|
||||
read -p "Resolution: " resolution
|
||||
echo $resolution >> "$done_file"
|
||||
mv "$done_file" "${tasks_dir}/.done/"
|
||||
notesSync
|
||||
else
|
||||
PS3="Mark as Done, file #: "
|
||||
echo "Mark a task as DONE ($(ls ${tasks_dir}/.done/ | wc -l))."
|
||||
select file in "${files[@]##*/}"; do
|
||||
{
|
||||
mv "${tasks_dir}/${file}" "${tasks_dir}/.done/"
|
||||
notesSync
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## REMOVE a task from a list
|
||||
elif [ "$1" = "remove" ] || [ "$1" = "rm" ]; then
|
||||
notesSync
|
||||
files=( $tasks_dir/*.md )
|
||||
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
remove_file=${files[$index]}
|
||||
echo "Removing $remove_file"
|
||||
rm "$remove_file"
|
||||
notesSync
|
||||
else
|
||||
PS3="Remove task #: "
|
||||
echo "Select a task to DELETE."
|
||||
select file in "${files[@]##*/}"; do
|
||||
{
|
||||
echo "Removing $file"
|
||||
rm "${tasks_dir}/${file}"
|
||||
notesSync
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## COPY content a task from a list
|
||||
elif [ "$1" = "copy" ] || [ "$1" = "c" ]; then
|
||||
files=( $tasks_dir/*.md )
|
||||
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
copy_file=${files[$index]}
|
||||
echo "Copied content of $copy_file"
|
||||
xclip -sel c < "$copy_file"
|
||||
else
|
||||
PS3="Copy file content #: "
|
||||
echo "Select a task to COPY Content."
|
||||
select file in "${files[@]##*/}"; do
|
||||
{
|
||||
echo "Copied content of $file"
|
||||
xclip -sel c < "${tasks_dir}/${file}"
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## Move a task to a project
|
||||
elif [ "$1" = "move" ] || [ "$1" = "m" ]; then
|
||||
notesSync
|
||||
files=( $tasks_dir/*.md )
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
move_file=${files[$index]}
|
||||
editFile "$move_file"
|
||||
else
|
||||
PS3="Move file #: "
|
||||
echo "Please select a file to MOVE."
|
||||
select file in "${files[@]##*/}"; do
|
||||
{
|
||||
echo "Moving $file"
|
||||
PS3="Select Project: "
|
||||
echo "Select a project"
|
||||
cd $tasks_dir
|
||||
dirs=( */ )
|
||||
# Select a project to move the item to
|
||||
select directory in "${dirs[@]}"; do
|
||||
{
|
||||
mv "${tasks_dir}/${file}" "${tasks_dir}/${directory}"
|
||||
break
|
||||
} || {
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## REMOVE a project
|
||||
elif [ "$1" = "remove-project" ] || [ "$1" = "rp" ]; then
|
||||
cd "$tasks_dir"
|
||||
dirs=( */ )
|
||||
|
||||
if ! [ "$2" = "" ]; then
|
||||
index=($2-1)
|
||||
remove_project=${dirs[$index]}
|
||||
echo "Removing $remove_project"
|
||||
# move all project tasks to parent
|
||||
mv "${tasks_dir}/${remove_project}"* "${tasks_dir}/" 2>/dev/null
|
||||
rm -rf "${tasks_dir}/${remove_project}"
|
||||
notesSync
|
||||
else
|
||||
PS3="Remove Project #: "
|
||||
echo "Select a Project to DELETE."
|
||||
select dir in "${dirs[@]}"; do
|
||||
{
|
||||
echo "Removing $dir"
|
||||
# move all project tasks to parent
|
||||
mv "${tasks_dir}/${dir}"* "${tasks_dir}/" 2>/dev/null
|
||||
rm -rf "${tasks_dir}/${dir}"
|
||||
notesSync
|
||||
break
|
||||
} ||
|
||||
{
|
||||
echo "bad choice"
|
||||
break
|
||||
}
|
||||
done
|
||||
fi
|
||||
|
||||
## CREATE a project
|
||||
elif [ "$1" = "project" ] || [ "$1" = "p" ]; then
|
||||
read -p "Create new project: " project
|
||||
mkdir "$tasks_dir/$project"
|
||||
|
||||
## CREATE a task (default)
|
||||
else
|
||||
|
||||
createtask
|
||||
fi
|
||||
Loading…
Reference in a new issue