Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>`
```

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 |
Expand Down
7 changes: 7 additions & 0 deletions src/tix/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
tix browse $TICKETS_DIR (default ~/.claude/tickets)
tix <project> browse ~/.claude/tickets/<project>/ (centralized layout)
and chdir into the project's code repo so pickup works.
tix --mini narrow-pane reverse-chrono reader (composes with <project>).

Code-repo lookup root defaults to ~/Documents/code; override with $TIX_CODE_DIR.
"""
Expand Down Expand Up @@ -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()
Expand Down
146 changes: 146 additions & 0 deletions src/tix/mini.py
Original file line number Diff line number Diff line change
@@ -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())
111 changes: 52 additions & 59 deletions src/tix/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` (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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading