From fabaf8aa9f293d64a68b286ac6b810cfb3978050 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 11 Jul 2026 11:44:09 +0200 Subject: [PATCH] feat: new task-tui app --- ayo.sh | 17 +- example.conf | 3 + task-tui | 814 +++++++++++++++++++++++++++++++++++++++++++++++++++ tasks.sh | 271 ----------------- 4 files changed, 819 insertions(+), 286 deletions(-) create mode 100755 task-tui delete mode 100755 tasks.sh diff --git a/ayo.sh b/ayo.sh index 9df9ce2..61bb451 100755 --- a/ayo.sh +++ b/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 "$@" diff --git a/example.conf b/example.conf index ef19776..619c154 100644 --- a/example.conf +++ b/example.conf @@ -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" diff --git a/task-tui b/task-tui new file mode 100755 index 0000000..832da6a --- /dev/null +++ b/task-tui @@ -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 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 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 ", 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 ", 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:])) diff --git a/tasks.sh b/tasks.sh deleted file mode 100755 index 31bfb06..0000000 --- a/tasks.sh +++ /dev/null @@ -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