diff --git a/README.md b/README.md index 8ca7199..39afe19 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ add-zsh-hook chpwd _tix_tickets_dir _tix_tickets_dir ``` +### Mini reader + +For narrow sidecars (think tmux pane next to an editor): + +```bash +tix --mini # flat reverse-chrono list, ≥20 cols +tix my-project --mini # same project resolution as `tix ` +``` + +Mini surfaces a single signal — *what was filed recently, can I pick one +up* — and gets out of the way. Done/cancelled hidden; `↑`/`↓` move, `Enter` +opens in `glow`/`$PAGER`, `p` spawns `wt`, `q` quits. No filters, no +groups, no rescope — use plain `tix` for that. + ## Keys | Key | Action | diff --git a/src/tix/__main__.py b/src/tix/__main__.py index 61b67f0..547893a 100644 --- a/src/tix/__main__.py +++ b/src/tix/__main__.py @@ -3,6 +3,7 @@ tix browse $TICKETS_DIR (default ~/.claude/tickets) tix browse ~/.claude/tickets// (centralized layout) and chdir into the project's code repo so pickup works. + tix --mini narrow-pane reverse-chrono reader (composes with ). Code-repo lookup root defaults to ~/Documents/code; override with $TIX_CODE_DIR. """ @@ -64,6 +65,12 @@ def main(argv=None): if not resolve_project(proj): return 1 + if "--mini" in argv: + argv = [a for a in argv if a != "--mini"] + from . import mini + sys.argv = ["tix", *argv] + return mini.main() + from . import tui sys.argv = ["tix", *argv] return tui.main() diff --git a/src/tix/mini.py b/src/tix/mini.py new file mode 100644 index 0000000..5f9fdca --- /dev/null +++ b/src/tix/mini.py @@ -0,0 +1,146 @@ +"""tix --mini — narrow-pane reverse-chrono ticket reader. + +A stripped-down sibling of `tui.py`: flat list, newest `created:` first, +done/cancelled hidden. ↑/↓ to move, Enter opens the brief in glow/$PAGER, +`p` spawns a `wt` lane. Targets ≥20 column panes (think tmux sidecar). + +Reuses tui's `Ticket` parser, `parse_created`, and the module-level +`open_in_pager` / `pickup_ticket` helpers — mini is a thinner renderer +over the identical model layer.""" +import curses +import sys + +from .tui import ( + CANCELLED_STATUSES, + STATUS_META, + TICKETS_DIR, + load_tickets, + open_in_pager, + pickup_ticket, + relative_age, + run_preload_hook, +) + +# Statuses hidden from mini's flat list — done + every cancelled alias. +_HIDDEN = {s.lower() for s in CANCELLED_STATUSES} | {"done"} + + +def build_rows(tickets): + """Filter + sort tickets for mini's flat list. + + Hides done/cancelled (case-insensitive — pre-migration title-case + `Done`/`Canceled` also drop). Sort: `created` desc; ties broken by id. + Missing `created` (0.0) sinks via -created negation (parity with + tui.App.sort_within_group `created` mode).""" + keep = [t for t in tickets if t.status.lower() not in _HIDDEN] + return sorted(keep, key=lambda t: (-t.created, t.id)) + + +def _status_icon(ticket): + return STATUS_META.get(ticket.status, ("·", "muted", 9))[0] + + +def _draw(stdscr, rows, sel, top): + stdscr.erase() + h, w = stdscr.getmaxyx() + if w < 20: + try: + stdscr.addstr(0, 0, "pane too narrow"[: max(0, w - 1)]) + except curses.error: + pass + stdscr.refresh() + return + body_h = max(0, h - 1) + for i in range(body_h): + idx = top + i + if idx >= len(rows): + break + t = rows[idx] + icon = _status_icon(t) + age = relative_age(t.created) or "" + age_pad = f"{age:>4}" + # Layout: icon (col 0) + space + title + right-aligned age. + age_x = max(0, w - len(age_pad) - 1) + title_x = 2 + title_w = max(0, age_x - title_x - 1) + attr = curses.A_REVERSE if idx == sel else 0 + try: + if idx == sel: + stdscr.addstr(i, 0, " " * (w - 1), attr) + stdscr.addstr(i, 0, icon, attr | curses.A_BOLD) + stdscr.addstr(i, title_x, t.title[:title_w], attr) + stdscr.addstr(i, age_x, age_pad, attr | curses.A_DIM) + except curses.error: + pass + # Footer hint. + if h >= 1: + hint = "↑↓ ⏎ open · p pickup · q quit" + try: + stdscr.addstr(h - 1, 0, hint[: max(0, w - 1)], curses.A_DIM) + except curses.error: + pass + stdscr.refresh() + + +def _run(stdscr): + curses.curs_set(0) + stdscr.keypad(True) + stdscr.timeout(-1) + rows = build_rows(load_tickets()) + sel = 0 + top = 0 + while True: + h, _ = stdscr.getmaxyx() + body_h = max(1, h - 1) + if sel < top: + top = sel + elif sel >= top + body_h: + top = sel - body_h + 1 + top = max(0, min(top, max(0, len(rows) - body_h))) + _draw(stdscr, rows, sel, top) + ch = stdscr.getch() + if ch in (ord("q"), 27, 3): # q / esc / Ctrl-C + return + if not rows: + continue + if ch in (curses.KEY_DOWN,): + sel = min(len(rows) - 1, sel + 1) + elif ch in (curses.KEY_UP,): + sel = max(0, sel - 1) + elif ch == curses.KEY_NPAGE: + sel = min(len(rows) - 1, sel + body_h) + elif ch == curses.KEY_PPAGE: + sel = max(0, sel - body_h) + elif ch == curses.KEY_HOME: + sel = 0 + elif ch == curses.KEY_END: + sel = len(rows) - 1 + elif ch in (curses.KEY_ENTER, 10, 13): + open_in_pager(stdscr, rows[sel].path) + elif ch == ord("p"): + ticket = rows[sel] + pickup_ticket(stdscr, ticket) + rows = build_rows(load_tickets()) + # Re-find the same path to keep cursor steady; else clamp. + for i, t in enumerate(rows): + if t.path == ticket.path: + sel = i + break + else: + sel = min(sel, max(0, len(rows) - 1)) + + +def main(): + if not TICKETS_DIR.is_dir(): + print(f"tix: no ticket directory at {TICKETS_DIR}", file=sys.stderr) + return 1 + run_preload_hook() + if not load_tickets(): + print(f"tix: no tickets found under {TICKETS_DIR}", file=sys.stderr) + return 1 + curses.wrapper(_run) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tix/tui.py b/src/tix/tui.py index 371ef7a..9364a07 100755 --- a/src/tix/tui.py +++ b/src/tix/tui.py @@ -403,6 +403,56 @@ def group_sort_key(name): return (name.startswith("_"), name.lower()) +def open_in_pager(stdscr, path): + """Suspend curses, open `path` in glow/$PAGER/less, restore curses. + Shared between default tix (Enter) and mini (Enter). Same fallback chain.""" + pager = shutil.which("glow") + cmd = [pager, "-p", str(path)] if pager else \ + [os.environ.get("PAGER", "less"), str(path)] + curses.def_prog_mode() + curses.endwin() + try: + subprocess.run(cmd) + except (OSError, subprocess.SubprocessError): + pass + curses.reset_prog_mode() + if stdscr is not None: + stdscr.refresh() + + +def pickup_ticket(stdscr, ticket): + """Suspend curses, fetch+ff main, spawn `wt ` (or `wt --ralph` for + epics), restore curses. Writes status=active on the ticket brief. + Shared between default tix (`p`) and mini (`p`).""" + wt = shutil.which("wt") or "wt" + curses.def_prog_mode() + curses.endwin() + try: + in_repo = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, + ) + if in_repo.returncode != 0: + print("tix: cwd is not a git repo — run tix from a repo root.") + input("press enter to return…") + else: + write_status(ticket.path, "active") + ticket.status = "active" + subprocess.run(["git", "fetch", "--quiet", "origin"]) + subprocess.run(["git", "checkout", "main"]) + subprocess.run(["git", "merge", "--ff-only", "origin/main"]) + wt_env = {**os.environ, + "WT_NO_WATCH": os.environ.get("WT_NO_WATCH", "1")} + wt_cmd = ([wt, "--ralph", ticket.slug] if ticket.is_epic + else [wt, ticket.slug]) + subprocess.run(wt_cmd, env=wt_env) + except (OSError, subprocess.SubprocessError): + pass + curses.reset_prog_mode() + if stdscr is not None: + stdscr.refresh() + + class App: def __init__(self): self.tickets = load_tickets() @@ -841,17 +891,7 @@ def show_help(self, stdscr): pass def open_ticket(self, stdscr, ticket): - pager = shutil.which("glow") - cmd = [pager, "-p", str(ticket.path)] if pager else \ - [os.environ.get("PAGER", "less"), str(ticket.path)] - curses.def_prog_mode() - curses.endwin() - try: - subprocess.run(cmd) - except Exception: - pass - curses.reset_prog_mode() - stdscr.refresh() + open_in_pager(stdscr, ticket.path) def open_url(self, ticket): if not ticket.url: @@ -945,55 +985,8 @@ def capture_buffer(self, stdscr, seed=""): # ---- ticket actions ---------------------------------------------- def pickup_ticket(self, stdscr, ticket): - """Foreground-suspend curses and hand off to `wt`. The flow mirrors a - manual pickup: fetch + check out main + fast-forward + spawn lane. - `wt` itself opens the lane in its own tmux window per WT_LAYOUT and - returns — wrapping that in our own `tmux new-window` would flash an - extra window before wt's real one. - - Epics (a folder with `_epic.md`) hand off via `wt --ralph` so the lane - runs the Ralph loop (epic-parse → ralph.sh, one story per iteration) - instead of degrading to a single lane that reads `_epic.md` as a flat - brief. Story order is the confirmed `epic-stories` block in `_epic.md`.""" - wt = shutil.which("wt") or "wt" - curses.def_prog_mode() - curses.endwin() - try: - # Confirm cwd is a git repo — else wt would fail silently and no - # lane window would spawn, which is the exact symptom we're fixing. - in_repo = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, - ) - if in_repo.returncode != 0: - print("tix: cwd is not a git repo — run tix from a repo root.") - input("press enter to return…") - else: - # Mark in-progress up front so the brief reflects the pickup - # immediately. The reconciler keeps deriving `active` from the - # live lane after this, but we don't wait for it to run. - write_status(ticket.path, "active") - ticket.status = "active" - # /pickup-style base sync: fetch + checkout main + ff merge. - # Each step is best-effort; wt still runs even on partial sync - # so a transient fetch failure doesn't block the lane. - subprocess.run(["git", "fetch", "--quiet", "origin"]) - subprocess.run(["git", "checkout", "main"]) - subprocess.run(["git", "merge", "--ff-only", "origin/main"]) - # Default pickups to a single pane (no auto lane-watch monitor - # split). Respect an explicit ambient WT_NO_WATCH if the user - # set one — otherwise force "1" so a stale shell that never - # exported it still gets the one-pane layout. - wt_env = {**os.environ, - "WT_NO_WATCH": os.environ.get("WT_NO_WATCH", "1")} - wt_cmd = ([wt, "--ralph", ticket.slug] if ticket.is_epic - else [wt, ticket.slug]) - subprocess.run(wt_cmd, env=wt_env) - except OSError: - pass - curses.reset_prog_mode() - stdscr.refresh() path = ticket.path + pickup_ticket(stdscr, ticket) self.rebuild() self.reselect_path(path) diff --git a/tests/test_mini.py b/tests/test_mini.py new file mode 100644 index 0000000..50254ef --- /dev/null +++ b/tests/test_mini.py @@ -0,0 +1,124 @@ +"""Tests for the mini reader. Render layer (curses) is not exercised — only +the pure data layer (`build_rows`) and CLI routing.""" +import sys +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" / "tickets" + + +def _purge(monkeypatch): + monkeypatch.setenv("TICKETS_DIR", str(FIXTURES)) + for mod in ("tix.mini", "tix.tui", "tix"): + sys.modules.pop(mod, None) + + +def test_build_rows_sorts_newest_first(monkeypatch, tmp_path): + """build_rows must order tickets by `created` desc (parity with tui's + sort_within_group `created` mode), missing-created sinks to the bottom.""" + tree = tmp_path / "tickets" + (tree / "area").mkdir(parents=True) + (tree / "area" / "old.md").write_text( + "---\nstatus: open\ncreated: 2026-01-01T00:00:00Z\n---\n# old\n", + encoding="utf-8", + ) + (tree / "area" / "new.md").write_text( + "---\nstatus: open\ncreated: 2026-05-01T00:00:00Z\n---\n# new\n", + encoding="utf-8", + ) + (tree / "area" / "no-date.md").write_text( + "---\nstatus: open\n---\n# no-date\n", + encoding="utf-8", + ) + monkeypatch.setenv("TICKETS_DIR", str(tree)) + for mod in ("tix.mini", "tix.tui", "tix"): + sys.modules.pop(mod, None) + + from tix import mini, tui + tickets = tui.load_tickets() + rows = mini.build_rows(tickets) + slugs = [t.slug for t in rows] + # `new` must precede `old` — that's the load-bearing assertion: frontmatter + # `created:` is parsed + ordered desc. `no-date` falls back to fs birthtime + # (parity with tui.parse_created), so its position is environment-dependent + # — just confirm it surfaces. + assert slugs.index("new") < slugs.index("old") + assert "no-date" in slugs + + +def test_build_rows_hides_done_and_cancelled(monkeypatch, tmp_path): + tree = tmp_path / "tickets" + (tree / "area").mkdir(parents=True) + for status in ("open", "draft", "active", "done", "cancelled"): + (tree / "area" / f"{status}.md").write_text( + f"---\nstatus: {status}\ncreated: 2026-05-01T00:00:00Z\n---\n# {status}\n", + encoding="utf-8", + ) + monkeypatch.setenv("TICKETS_DIR", str(tree)) + for mod in ("tix.mini", "tix.tui", "tix"): + sys.modules.pop(mod, None) + + from tix import mini, tui + rows = mini.build_rows(tui.load_tickets()) + slugs = {t.slug for t in rows} + assert slugs == {"open", "draft", "active"} + + +def test_build_rows_filters_case_insensitive(monkeypatch, tmp_path): + """Pre-migration title-case statuses (Done, Canceled) must also hide.""" + tree = tmp_path / "tickets" + (tree / "area").mkdir(parents=True) + for status in ("Done", "Canceled", "Cancelled"): + (tree / "area" / f"{status}.md").write_text( + f"---\nstatus: {status}\ncreated: 2026-05-01T00:00:00Z\n---\n# x\n", + encoding="utf-8", + ) + (tree / "area" / "keep.md").write_text( + "---\nstatus: open\ncreated: 2026-05-01T00:00:00Z\n---\n# keep\n", + encoding="utf-8", + ) + monkeypatch.setenv("TICKETS_DIR", str(tree)) + for mod in ("tix.mini", "tix.tui", "tix"): + sys.modules.pop(mod, None) + + from tix import mini, tui + rows = mini.build_rows(tui.load_tickets()) + assert {t.slug for t in rows} == {"keep"} + + +def test_main_routes_mini_flag(monkeypatch, tmp_path): + """`tix --mini` must dispatch to mini.main, not tui.main.""" + monkeypatch.setenv("TICKETS_DIR", str(FIXTURES)) + for mod in ("tix.mini", "tix.tui", "tix.__main__", "tix"): + sys.modules.pop(mod, None) + + from tix import __main__ as entry + called = {"mini": 0, "tui": 0} + from tix import mini, tui + monkeypatch.setattr(mini, "main", lambda: called.__setitem__("mini", 1) or 0) + monkeypatch.setattr(tui, "main", lambda: called.__setitem__("tui", 1) or 0) + assert entry.main(["--mini"]) == 0 + assert called == {"mini": 1, "tui": 0} + + +def test_main_routes_project_mini(monkeypatch, tmp_path): + """`tix --mini` resolves project then dispatches to mini.""" + home = tmp_path / "home" + central = home / ".claude" / "tickets" / "proj" + central.mkdir(parents=True) + code_repo = tmp_path / "code" / "proj" + (code_repo / ".git").mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("TIX_CODE_DIR", str(tmp_path / "code")) + monkeypatch.delenv("TICKETS_DIR", raising=False) + monkeypatch.chdir(tmp_path) + for mod in ("tix.mini", "tix.tui", "tix.__main__", "tix"): + sys.modules.pop(mod, None) + + from tix import __main__ as entry + from tix import mini, tui + flags = {"mini": 0} + monkeypatch.setattr(mini, "main", lambda: flags.__setitem__("mini", 1) or 0) + monkeypatch.setattr(tui, "main", lambda: 99) + assert entry.main(["proj", "--mini"]) == 0 + assert flags["mini"] == 1 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0cb9de8..ad752b0 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -78,3 +78,10 @@ def test_resolve_project_missing_returns_false(monkeypatch, tmp_path, capsys): from tix.__main__ import resolve_project assert resolve_project("nope") is False assert "no ticket directory" in capsys.readouterr().err + + +def test_pager_and_pickup_helpers_exposed(): + """Mini imports these — they must live at module scope, not on App.""" + from tix import tui + assert callable(getattr(tui, "open_in_pager", None)) + assert callable(getattr(tui, "pickup_ticket", None))