feat: new task-tui app

This commit is contained in:
ayo 2026-07-11 11:44:09 +02:00
parent 5f99fb7b25
commit fabaf8aa9f
4 changed files with 819 additions and 286 deletions

17
ayo.sh
View file

@ -15,19 +15,6 @@ case $1 in
. ${scripts_dir}/journal.sh -t "$@" . ${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 nd) # Notes diff
. ${scripts_dir}/notes.sh diff . ${scripts_dir}/notes.sh diff
;; ;;
@ -91,10 +78,10 @@ case $1 in
. ${scripts_dir}/git.sh "$@" . ${scripts_dir}/git.sh "$@"
;; ;;
n | notes) n | notes)
. ${scripts_dir}/notes.sh "$@" . ${scripts_dir}/notes.sh $@
;; ;;
t | tasks) t | tasks)
. ${scripts_dir}/tasks.sh "$@" TASKS_HOME=${TASKS_HOME} /${scripts_dir}/task-tui "${@:2}"
;; ;;
j | journal) j | journal)
. ${scripts_dir}/journal.sh "$@" . ${scripts_dir}/journal.sh "$@"

View file

@ -21,3 +21,6 @@ agent_modelfile="${HOME}/path-to/Modelfile-agent"
secondary=HDMI-1 secondary=HDMI-1
main=HDMI-0 main=HDMI-0
internal=eDP-1-1 internal=eDP-1-1
# tasks
TASK_HOME="%{HOME}/Notes/Tasks"

814
task-tui Executable file
View 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
View file

@ -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