tix-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tix/tui.py ADDED
@@ -0,0 +1,1183 @@
1
+ #!/usr/bin/env python3
2
+ """tix — terminal ticket explorer for ~/.claude/tickets.
3
+
4
+ Keyboard-driven, Linear-like TUI over the local ticket briefs. The list
5
+ view groups tickets by their on-disk folder; Enter opens the full
6
+ markdown in glow's pager. Zero deps beyond the stdlib + glow on PATH.
7
+ """
8
+ import curses
9
+ import json
10
+ import os
11
+ import re
12
+ import shlex
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ TICKETS_DIR = Path(os.environ.get("TICKETS_DIR", Path.home() / ".claude" / "tickets"))
20
+ LANES_FILE = Path(os.environ.get("ACTIVE_LANES_FILE",
21
+ Path.home() / ".claude" / "active-lanes.json"))
22
+
23
+ # Linear workspace slug — used to derive a ticket URL from its `linear:` id.
24
+ # Set LINEAR_WORKSPACE in the environment; unset → no derived URL.
25
+ LINEAR_WORKSPACE = os.environ.get("LINEAR_WORKSPACE", "")
26
+
27
+ # Files under TICKETS_DIR that are not tickets — skipped by the loader.
28
+ META_FILES = {"README.md", "_TEMPLATE.md", "_EPIC-TEMPLATE.md", "_CHILD-TEMPLATE.md"}
29
+
30
+ # status label -> (icon, color name, sort rank). Lowercase keys are the current
31
+ # schema (~/.claude/tickets/README.md); title-case keys are legacy (pre-migration).
32
+ STATUS_META = {
33
+ "active": ("◐", "inprogress", 0),
34
+ "open": ("○", "todo", 1),
35
+ "draft": ("◌", "backlog", 2),
36
+ "done": ("●", "done", 3),
37
+ "cancelled": ("✕", "muted", 6),
38
+ "canceled": ("✕", "muted", 6),
39
+ "In Progress": ("◐", "inprogress", 0),
40
+ "In Review": ("◑", "inreview", 1),
41
+ "Todo": ("○", "todo", 2),
42
+ "Backlog": ("○", "backlog", 3),
43
+ "Done": ("●", "done", 4),
44
+ "Canceled": ("✕", "muted", 5),
45
+ "Cancelled": ("✕", "muted", 5),
46
+ }
47
+ DEFAULT_STATUS_META = ("·", "muted", 9)
48
+ FILTER_ORDER = ["active", "open", "draft", "done", "cancelled",
49
+ "In Progress", "In Review", "Todo", "Backlog"]
50
+ CANCELLED_STATUSES = {"cancelled", "canceled", "Cancelled", "Canceled"}
51
+
52
+ # Split-pane thresholds. Below the combined minimum, preview is hidden and
53
+ # the list reclaims the full width.
54
+ LIST_MIN_W = 38
55
+ PREVIEW_MIN_W = 32
56
+
57
+ # Priority bucket → (sort rank, color name). Missing/blank priorities sort last
58
+ # with rank 9 so prioritized work bubbles to the top of each group.
59
+ PRIORITY_META = {
60
+ "P0": (0, "p0"),
61
+ "P1": (1, "p1"),
62
+ "P2": (2, "p2"),
63
+ "P3": (3, "p3"),
64
+ }
65
+ PRIORITY_ORDER = ["P0", "P1", "P2", "P3"]
66
+ PRIORITY_DEFAULT_RANK = 9
67
+
68
+ # Fixed area bucket set (kept in sync with ~/.claude/tickets/README.md). The
69
+ # minibuffer move picker indexes into this list — extend with care.
70
+ AREAS = ["integrations", "ops", "platform", "spikes", "tooling"]
71
+
72
+ HELP_TEXT = """tix — keyboard reference
73
+
74
+ NAVIGATION
75
+ ↑ / k move up
76
+ ↓ / j move down
77
+ PgUp / Ctrl-U page (half) up
78
+ PgDn / Ctrl-D page (half) down
79
+ g jump to top
80
+ G jump to bottom
81
+ ← / h collapse current group
82
+ → / l / ⏎ open ticket in glow (or expand group)
83
+ space toggle current group
84
+ C / z collapse / expand all groups
85
+
86
+ FILTER + SEARCH
87
+ tab / shift-tab cycle status filter chip
88
+ 1-9 jump to filter chip N
89
+ / start text search (esc to cancel, ⏎ to commit)
90
+
91
+ TICKET ACTIONS
92
+ p pickup → wt <slug> (suspend curses, run, return)
93
+ e edit brief in $EDITOR; reload after
94
+ R rescope → $EDITOR scratch → claude "/rescope <slug> <text>"
95
+ n new ticket → $EDITOR scratch → claude "/scope <text>"
96
+ N new from clipboard (pbpaste) → $EDITOR → claude "/scope"
97
+ +/= / - raise / lower priority (P0..P3, blank); writes frontmatter
98
+ i toggle in-progress (sticky: pins `active` without a lane)
99
+ d toggle done (sticky: trumps reconciler)
100
+ x toggle cancel (sticky terminal; ticket hides from default views)
101
+ m move ticket to a different area (numeric pick)
102
+ o open the ticket's URL (legacy linear: field)
103
+ r force reload (also auto-reloads every 2s on tickets-dir change)
104
+ ? this help
105
+ q / esc quit
106
+
107
+ HIDE RULES
108
+ cancelled hidden everywhere except `cancelled` chip
109
+ done hidden everywhere except `done` chip
110
+ All chip shows draft / open / active only.
111
+
112
+ STATUS LIFECYCLE
113
+ draft → /scope plants it; reconciler preserves until a lane spawns
114
+ open → default
115
+ active → derived from live worktree / branch, OR sticky `i` mark in tix
116
+ done → derived from merged PR OR sticky `d` mark in tix
117
+ cancelled → sticky `x` mark; trumps every derived signal
118
+
119
+ The reconciler runs on every tix launch + every `wt` spawn.
120
+ """
121
+
122
+
123
+ def load_lanes():
124
+ """slug -> {path, branch, repo, last_commit} sidecar emitted by
125
+ ticket-status-sync.py. Best-effort: missing/corrupt → empty dict, tix
126
+ just hides the lane-state section."""
127
+ try:
128
+ return json.loads(LANES_FILE.read_text(encoding="utf-8"))
129
+ except (OSError, ValueError):
130
+ return {}
131
+
132
+
133
+ def read_agent_state(wt_path):
134
+ """Single-file read — the state machine writes one line per transition,
135
+ so this is the live agent indicator. Empty / missing → just hide."""
136
+ try:
137
+ return (Path(wt_path) / ".claude" / "agent-state").read_text(
138
+ encoding="utf-8", errors="replace").strip()
139
+ except OSError:
140
+ return ""
141
+
142
+
143
+ AGENT_STATE_COLORS = {
144
+ "ACTIVE": "inprogress",
145
+ "WAITING": "p1",
146
+ "IDLE": "todo",
147
+ "RUNNING": "inreview",
148
+ "DONE": "done",
149
+ "FAILED": "p0",
150
+ }
151
+
152
+
153
+ def agent_state_color(state):
154
+ """Map the leading token of an agent-state line (`ACTIVE:tool`,
155
+ `WAITING:code:detail`, …) onto a tix color name."""
156
+ head = state.split(":", 1)[0] if state else ""
157
+ return AGENT_STATE_COLORS.get(head, "muted")
158
+
159
+
160
+ def dir_signature():
161
+ """Sum of every brief's mtime_ns under TICKETS_DIR. Order-independent —
162
+ a single bumped/added/removed file changes the sum, so tix can poll this
163
+ cheaply on idle ticks and reload only when something actually changed."""
164
+ total = 0
165
+ try:
166
+ for path in TICKETS_DIR.rglob("*.md"):
167
+ try:
168
+ total += path.stat().st_mtime_ns
169
+ except OSError:
170
+ continue
171
+ except OSError:
172
+ pass
173
+ return total
174
+
175
+
176
+ def is_tombstone(path):
177
+ """A tombstone is a brief whose only content is `moved -> <path>`. The
178
+ contract (~/.claude/tickets/README.md) defines them as redirects; tix
179
+ should not surface them as tickets. We sniff only the first non-empty
180
+ line so the check stays cheap on the rglob hot path."""
181
+ try:
182
+ with path.open(encoding="utf-8", errors="replace") as fh:
183
+ for line in fh:
184
+ stripped = line.strip()
185
+ if not stripped:
186
+ continue
187
+ return stripped.startswith("moved -> ")
188
+ except OSError:
189
+ return False
190
+ return False
191
+
192
+
193
+ def write_frontmatter_field(path, key_name, value):
194
+ """Insert, replace, or remove a frontmatter field. value="" clears the line.
195
+ No-op if the file has no frontmatter. Mirrors the line-edit pattern in
196
+ ticket-status-sync.py — flat key:value, no PyYAML."""
197
+ try:
198
+ text = path.read_text(encoding="utf-8")
199
+ except OSError:
200
+ return
201
+ if not text.startswith("---"):
202
+ return
203
+ lines = text.splitlines(keepends=True)
204
+ fm_end = None
205
+ for i in range(1, len(lines)):
206
+ if lines[i].strip() == "---":
207
+ fm_end = i
208
+ break
209
+ if fm_end is None:
210
+ return
211
+ field_idx = None
212
+ for i in range(1, fm_end):
213
+ key, sep, _ = lines[i].partition(":")
214
+ if sep and key.strip() == key_name:
215
+ field_idx = i
216
+ break
217
+ if value:
218
+ new_line = f"{key_name}: {value}\n"
219
+ if field_idx is not None:
220
+ lines[field_idx] = new_line
221
+ else:
222
+ lines.insert(fm_end, new_line)
223
+ elif field_idx is not None:
224
+ del lines[field_idx]
225
+ path.write_text("".join(lines), encoding="utf-8")
226
+
227
+
228
+ def write_priority(path, new_priority):
229
+ write_frontmatter_field(path, "priority", new_priority)
230
+
231
+
232
+ def write_status(path, new_status):
233
+ write_frontmatter_field(path, "status", new_status)
234
+
235
+
236
+ def parse_frontmatter(path):
237
+ text = path.read_text(encoding="utf-8", errors="replace")
238
+ fm = {}
239
+ if not text.startswith("---"):
240
+ return fm
241
+ end = text.find("\n---", 3)
242
+ if end == -1:
243
+ return fm
244
+ for line in text[3:end].splitlines():
245
+ line = line.strip()
246
+ if not line or line.startswith("#") or ":" not in line:
247
+ continue
248
+ key, _, val = line.partition(":")
249
+ key, val = key.strip(), val.strip()
250
+ if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]:
251
+ val = val[1:-1]
252
+ fm[key] = val
253
+ return fm
254
+
255
+
256
+ def clean_title(title, ticket_id):
257
+ title = re.sub(r"^\[[A-Za-z]+-\d+\]\s*", "", title.strip())
258
+ return title or ticket_id
259
+
260
+
261
+ class Ticket:
262
+ def __init__(self, path):
263
+ fm = parse_frontmatter(path)
264
+ self.path = path
265
+ self.is_epic = path.name == "_epic.md"
266
+ # Legacy = pre-migration schema: carried `id:`, no `linear:`/`area:`.
267
+ self.legacy = "id" in fm and "linear" not in fm and "area" not in fm
268
+ # An _epic.md represents its folder; everything else is its own slug.
269
+ self.slug = path.parent.name if self.is_epic else path.stem
270
+ self.linear = fm.get("linear", "").strip()
271
+ # Display identifier: Linear id if synced, else the slug (legacy: `id:`).
272
+ self.id = self.linear or fm.get("id") or self.slug
273
+ self.epic = fm.get("epic", "") or fm.get("parent", "")
274
+ self.area = fm.get("area", "")
275
+ self.status = fm.get("status", "").strip() or ("open" if self.is_epic else "")
276
+ # Priority is optional; missing = unprioritized (sorted last).
277
+ self.priority = fm.get("priority", "").strip().upper()
278
+ # URL is derived from `linear:` when LINEAR_WORKSPACE is set; a legacy
279
+ # stored `url:` is the fallback.
280
+ self.url = (f"https://linear.app/{LINEAR_WORKSPACE}/issue/{self.linear}"
281
+ if self.linear and LINEAR_WORKSPACE else fm.get("url", ""))
282
+ self.title = clean_title(fm.get("title", self.slug), self.slug)
283
+ self.group = path.parent.name
284
+
285
+ @property
286
+ def meta(self):
287
+ if self.is_epic:
288
+ return ("▸", "accent", -1)
289
+ return STATUS_META.get(self.status, DEFAULT_STATUS_META)
290
+
291
+ def body(self):
292
+ """Brief body text with frontmatter stripped. Cached per Ticket — the
293
+ preview pane re-reads on every keystroke, so paying disk I/O once is the
294
+ right trade for a ~50-ticket tree."""
295
+ if hasattr(self, "_body"):
296
+ return self._body
297
+ try:
298
+ text = self.path.read_text(encoding="utf-8", errors="replace")
299
+ except OSError:
300
+ text = ""
301
+ if text.startswith("---"):
302
+ end = text.find("\n---", 3)
303
+ if end >= 0:
304
+ text = text[end + 4:]
305
+ self._body = text.lstrip("\n")
306
+ return self._body
307
+
308
+
309
+ def load_tickets():
310
+ tickets = []
311
+ if TICKETS_DIR.is_dir():
312
+ for path in sorted(TICKETS_DIR.rglob("*.md")):
313
+ if path.name in META_FILES:
314
+ continue
315
+ # Skip other _*.md meta files, but keep _epic.md (the epic PRD).
316
+ if path.name.startswith("_") and path.name != "_epic.md":
317
+ continue
318
+ if is_tombstone(path):
319
+ continue
320
+ try:
321
+ tickets.append(Ticket(path))
322
+ except Exception:
323
+ continue
324
+ return tickets
325
+
326
+
327
+ def group_sort_key(name):
328
+ # underscore groups (_loose etc.) sink to the bottom
329
+ return (name.startswith("_"), name.lower())
330
+
331
+
332
+ class App:
333
+ def __init__(self):
334
+ self.tickets = load_tickets()
335
+ self.collapsed = set()
336
+ self.filter_idx = 0
337
+ self.query = ""
338
+ self.search_mode = False
339
+ # move_mode is None or the Ticket awaiting an area pick from the footer
340
+ # minibuffer (`m` enters; 1-N commits; esc cancels).
341
+ self.move_mode = None
342
+ self.sel = 0
343
+ self.top = 0
344
+ self.colors = {}
345
+ self._dir_sig = dir_signature()
346
+ self.lanes = load_lanes()
347
+ self.rebuild()
348
+
349
+ # ---- data ---------------------------------------------------------
350
+ def rebuild(self):
351
+ by_group = {}
352
+ for t in self.tickets:
353
+ by_group.setdefault(t.group, []).append(t)
354
+ for g in by_group:
355
+ by_group[g].sort(key=lambda t: (
356
+ PRIORITY_META.get(t.priority, (PRIORITY_DEFAULT_RANK, None))[0],
357
+ t.meta[2],
358
+ t.id,
359
+ ))
360
+ self.by_group = by_group
361
+ self.groups = sorted(by_group, key=group_sort_key)
362
+ # group_meta[g] = (is_epic_group, area). Epic groups live one level deeper
363
+ # than area groups, so we prefix the header with the area path for context.
364
+ self.group_meta = {}
365
+ for g, ts in by_group.items():
366
+ epic_t = next((t for t in ts if t.is_epic), None)
367
+ if epic_t:
368
+ parents = epic_t.path.parents
369
+ area = parents[1].name if len(parents) >= 2 else ""
370
+ self.group_meta[g] = (True, area)
371
+ else:
372
+ self.group_meta[g] = (False, "")
373
+ present = [s for s in FILTER_ORDER if any(t.status == s for t in self.tickets)]
374
+ self.filters = ["All"] + present
375
+ if self.filter_idx >= len(self.filters):
376
+ self.filter_idx = 0
377
+ self.rebuild_rows()
378
+
379
+ def passes(self, t):
380
+ f = self.filters[self.filter_idx]
381
+ # Cancelled + done tickets are hidden from every view except their
382
+ # explicit filter chip — the working list is for in-flight work.
383
+ if t.status in CANCELLED_STATUSES and f != "cancelled":
384
+ return False
385
+ if t.status.lower() == "done" and f != "done":
386
+ return False
387
+ if f != "All" and t.status != f:
388
+ return False
389
+ if self.query:
390
+ q = self.query.lower()
391
+ hay = (t.id + " " + t.title + " " + t.group + " " + t.area).lower()
392
+ if q not in hay:
393
+ return False
394
+ return True
395
+
396
+ def rebuild_rows(self):
397
+ rows = []
398
+ for g in self.groups:
399
+ visible = [t for t in self.by_group[g] if self.passes(t)]
400
+ if not visible:
401
+ continue
402
+ rows.append({"type": "group", "group": g,
403
+ "count": len(visible), "total": len(self.by_group[g])})
404
+ if g not in self.collapsed:
405
+ for t in visible:
406
+ rows.append({"type": "ticket", "ticket": t})
407
+ self.rows = rows
408
+ if self.sel >= len(rows):
409
+ self.sel = max(0, len(rows) - 1)
410
+
411
+ # ---- colors -------------------------------------------------------
412
+ def init_colors(self):
413
+ if not curses.has_colors():
414
+ return
415
+ curses.start_color()
416
+ try:
417
+ curses.use_default_colors()
418
+ except curses.error:
419
+ pass
420
+ spec = {
421
+ "inprogress": curses.COLOR_YELLOW,
422
+ "inreview": curses.COLOR_MAGENTA,
423
+ "todo": curses.COLOR_CYAN,
424
+ "backlog": curses.COLOR_BLUE,
425
+ "done": curses.COLOR_GREEN,
426
+ "muted": curses.COLOR_WHITE,
427
+ "group": curses.COLOR_WHITE,
428
+ "accent": curses.COLOR_CYAN,
429
+ "p0": curses.COLOR_RED,
430
+ "p1": curses.COLOR_YELLOW,
431
+ "p2": curses.COLOR_CYAN,
432
+ "p3": curses.COLOR_BLUE,
433
+ }
434
+ for i, (name, fg) in enumerate(spec.items(), start=1):
435
+ try:
436
+ curses.init_pair(i, fg, -1)
437
+ except curses.error:
438
+ curses.init_pair(i, fg, curses.COLOR_BLACK)
439
+ self.colors[name] = curses.color_pair(i)
440
+
441
+ def attr(self, name, extra=0):
442
+ return self.colors.get(name, 0) | extra
443
+
444
+ # ---- rendering ----------------------------------------------------
445
+ @staticmethod
446
+ def _put(win, y, x, text, attr=0, maxx=None):
447
+ if y < 0:
448
+ return
449
+ h, w = win.getmaxyx()
450
+ if y >= h or x >= w:
451
+ return
452
+ limit = w if maxx is None else min(w, maxx)
453
+ text = text[: max(0, limit - x)]
454
+ if not text:
455
+ return
456
+ try:
457
+ win.addstr(y, x, text, attr)
458
+ except curses.error:
459
+ pass
460
+
461
+ def panes(self, w):
462
+ """Return (list_w, preview_w). preview_w == 0 means hidden — the list
463
+ gets the full width back on narrow terminals."""
464
+ if w < LIST_MIN_W + PREVIEW_MIN_W + 1:
465
+ return w, 0
466
+ list_w = max(LIST_MIN_W, int(w * 0.48))
467
+ preview_w = w - list_w - 1
468
+ if preview_w < PREVIEW_MIN_W:
469
+ list_w = w - PREVIEW_MIN_W - 1
470
+ preview_w = PREVIEW_MIN_W
471
+ return list_w, preview_w
472
+
473
+ def draw(self, stdscr):
474
+ stdscr.erase()
475
+ h, w = stdscr.getmaxyx()
476
+ self.draw_header(stdscr, w)
477
+ body_h = max(0, h - 2)
478
+ self.clamp_viewport(body_h)
479
+ list_w, preview_w = self.panes(w)
480
+ for i in range(body_h):
481
+ idx = self.top + i
482
+ if idx >= len(self.rows):
483
+ break
484
+ self.draw_row(stdscr, 1 + i, list_w, idx, self.rows[idx])
485
+ if preview_w > 0:
486
+ sep_x = list_w
487
+ sep_attr = self.attr("muted", curses.A_DIM)
488
+ for i in range(body_h):
489
+ self._put(stdscr, 1 + i, sep_x, "│", sep_attr)
490
+ self.draw_preview(stdscr, sep_x + 2, 1, preview_w - 2, body_h)
491
+ self.draw_footer(stdscr, h, w)
492
+ stdscr.refresh()
493
+
494
+ def draw_preview(self, stdscr, x0, y0, w, h):
495
+ if w <= 0 or h <= 0:
496
+ return
497
+ row = self.current()
498
+ if not row:
499
+ return
500
+ if row["type"] == "group":
501
+ is_epic_g, area = self.group_meta.get(row["group"], (False, ""))
502
+ heading = f"{area} / {row['group']}" if is_epic_g and area else row["group"]
503
+ kind = "epic group" if is_epic_g else "area group"
504
+ self._put(stdscr, y0, x0, heading[:w], self.attr("accent", curses.A_BOLD), maxx=x0 + w)
505
+ sub = f"{row['count']}/{row['total']} tickets · {kind}"
506
+ self._put(stdscr, y0 + 1, x0, sub[:w], self.attr("muted", curses.A_DIM), maxx=x0 + w)
507
+ return
508
+
509
+ t = row["ticket"]
510
+ y = y0
511
+ # Title — bold, single line, truncated.
512
+ self._put(stdscr, y, x0, t.title[:w], curses.A_BOLD, maxx=x0 + w)
513
+ y += 1
514
+ # Pickup slug — exact arg for `wt`/`/pickup`. Surfaces what `p` will run.
515
+ if y - y0 < h:
516
+ self._put(stdscr, y, x0, f"pickup: {t.slug}"[:w],
517
+ self.attr("accent", curses.A_DIM), maxx=x0 + w)
518
+ y += 1
519
+ meta_bits = [b for b in (t.area, t.status, t.priority) if b]
520
+ if meta_bits:
521
+ color = t.meta[1] if not t.is_epic else "accent"
522
+ self._put(stdscr, y, x0, (" · ".join(meta_bits))[:w],
523
+ self.attr(color), maxx=x0 + w)
524
+ y += 1
525
+ kv = []
526
+ if t.id and t.id != t.slug:
527
+ kv.append(("id", t.id))
528
+ if t.epic:
529
+ kv.append(("epic", t.epic))
530
+ if t.linear:
531
+ kv.append(("linear", t.linear))
532
+ for key, val in kv:
533
+ if y - y0 >= h:
534
+ break
535
+ line = f"{key}: {val}"
536
+ self._put(stdscr, y, x0, line[:w], self.attr("muted", curses.A_DIM),
537
+ maxx=x0 + w)
538
+ y += 1
539
+ # In-progress block: only for tickets the reconciler marked `active`.
540
+ # Sidecar lookup is O(1); agent-state is one tiny file read per draw.
541
+ if t.status == "active" and t.slug in self.lanes and y - y0 < h:
542
+ lane = self.lanes[t.slug]
543
+ y += 1
544
+ if y - y0 >= h:
545
+ return
546
+ self._put(stdscr, y, x0, "── lane ─────────────"[:w],
547
+ self.attr("muted", curses.A_DIM), maxx=x0 + w)
548
+ y += 1
549
+ wt_path = lane.get("path", "")
550
+ home = str(Path.home())
551
+ rel = wt_path.replace(home, "~", 1) if wt_path.startswith(home) else wt_path
552
+ for label, val, color in (
553
+ ("path", rel, "muted"),
554
+ ("branch", lane.get("branch", ""), "muted"),
555
+ ):
556
+ if not val or y - y0 >= h:
557
+ continue
558
+ self._put(stdscr, y, x0, f"{label}: {val}"[:w],
559
+ self.attr(color, curses.A_DIM), maxx=x0 + w)
560
+ y += 1
561
+ state = read_agent_state(wt_path)
562
+ if state and y - y0 < h:
563
+ self._put(stdscr, y, x0, f"state: {state}"[:w],
564
+ self.attr(agent_state_color(state), curses.A_BOLD),
565
+ maxx=x0 + w)
566
+ y += 1
567
+ last = lane.get("last_commit", "")
568
+ if last and y - y0 < h:
569
+ self._put(stdscr, y, x0, f"last: {last}"[:w],
570
+ self.attr("muted"), maxx=x0 + w)
571
+ y += 1
572
+ if y - y0 >= h:
573
+ return
574
+ # Visual gap before body.
575
+ y += 1
576
+ body_lines = t.body().splitlines() or ["(empty)"]
577
+ for raw in body_lines:
578
+ if y - y0 >= h:
579
+ break
580
+ self._put(stdscr, y, x0, raw[:w], maxx=x0 + w)
581
+ y += 1
582
+
583
+ def draw_header(self, stdscr, w):
584
+ x = 0
585
+ self._put(stdscr, 0, x, " tix ", self.attr("accent", curses.A_REVERSE | curses.A_BOLD))
586
+ x += 6
587
+ for i, f in enumerate(self.filters):
588
+ label = f" {f} "
589
+ if i == self.filter_idx:
590
+ self._put(stdscr, 0, x, label, curses.A_REVERSE | curses.A_BOLD)
591
+ else:
592
+ self._put(stdscr, 0, x, label, curses.A_DIM)
593
+ x += len(label) + 1
594
+ matched = sum(1 for t in self.tickets if self.passes(t))
595
+ total = len(self.tickets)
596
+ summary = f"{matched}/{total} tickets"
597
+ self._put(stdscr, 0, max(x, w - len(summary) - 1), summary, self.attr("accent"))
598
+
599
+ def draw_row(self, stdscr, y, w, idx, row):
600
+ selected = idx == self.sel
601
+ if row["type"] == "group":
602
+ arrow = "▶" if row["group"] in self.collapsed else "▼"
603
+ is_epic_g, area = self.group_meta.get(row["group"], (False, ""))
604
+ if is_epic_g and area:
605
+ text = f"{arrow} {area} / {row['group']} (epic)"
606
+ else:
607
+ text = f"{arrow} {row['group']}"
608
+ count = f"({row['count']}/{row['total']})"
609
+ attr = curses.A_BOLD | (curses.A_REVERSE if selected else 0)
610
+ if selected:
611
+ self._put(stdscr, y, 0, " " * (w - 1), curses.A_REVERSE, maxx=w)
612
+ self._put(stdscr, y, 0, text, attr, maxx=w)
613
+ self._put(stdscr, y, max(0, w - len(count) - 1), count,
614
+ attr if selected else self.attr("muted", curses.A_DIM),
615
+ maxx=w)
616
+ return
617
+
618
+ t = row["ticket"]
619
+ icon, color, _ = t.meta
620
+ status = t.status
621
+ # Legacy tickets get a `~` marker; slugs are wider than Linear ids.
622
+ disp_id = (t.id + "~") if t.legacy else t.id
623
+ id_col = f"{disp_id[:13]:<13}"
624
+ prio_tag = t.priority if t.priority in PRIORITY_META else " "
625
+ prio_color = PRIORITY_META.get(t.priority, (None, "muted"))[1]
626
+ if selected:
627
+ self._put(stdscr, y, 0, " " * (w - 1), curses.A_REVERSE, maxx=w)
628
+ base = curses.A_REVERSE
629
+ self._put(stdscr, y, 2, icon, base | curses.A_BOLD, maxx=w)
630
+ self._put(stdscr, y, 4, f"{prio_tag:<2}",
631
+ base | curses.A_BOLD, maxx=w)
632
+ self._put(stdscr, y, 7, id_col, base | curses.A_BOLD, maxx=w)
633
+ title_x = 7 + len(id_col) + 1
634
+ avail = w - title_x - len(status) - 2
635
+ self._put(stdscr, y, title_x, t.title[: max(0, avail)], base, maxx=w)
636
+ self._put(stdscr, y, max(title_x, w - len(status) - 1), status,
637
+ base | curses.A_DIM, maxx=w)
638
+ else:
639
+ self._put(stdscr, y, 2, icon, self.attr(color, curses.A_BOLD), maxx=w)
640
+ self._put(stdscr, y, 4, f"{prio_tag:<2}",
641
+ self.attr(prio_color, curses.A_BOLD), maxx=w)
642
+ self._put(stdscr, y, 7, id_col, curses.A_DIM, maxx=w)
643
+ title_x = 7 + len(id_col) + 1
644
+ avail = w - title_x - len(status) - 2
645
+ self._put(stdscr, y, title_x, t.title[: max(0, avail)], maxx=w)
646
+ self._put(stdscr, y, max(title_x, w - len(status) - 1), status,
647
+ self.attr(color), maxx=w)
648
+
649
+ def draw_footer(self, stdscr, h, w):
650
+ y = h - 1
651
+ if self.move_mode is not None:
652
+ items = " ".join(f"{i+1}) {a}" for i, a in enumerate(AREAS))
653
+ text = f" move `{self.move_mode.slug}` → {items} esc cancel "
654
+ self._put(stdscr, y, 0, " " * (w - 1), curses.A_REVERSE)
655
+ self._put(stdscr, y, 0, text[:w],
656
+ self.attr("accent", curses.A_REVERSE | curses.A_BOLD))
657
+ return
658
+ if self.search_mode:
659
+ prompt = f"/{self.query}"
660
+ self._put(stdscr, y, 0, " " * (w - 1), curses.A_REVERSE)
661
+ self._put(stdscr, y, 0, prompt, curses.A_REVERSE)
662
+ try:
663
+ stdscr.move(y, min(len(prompt), w - 1))
664
+ except curses.error:
665
+ pass
666
+ return
667
+ hints = ("⏎ open · p pickup · e edit · R rescope · n new · m move · "
668
+ "+/− prio · i wip · d done · x cancel · ? help · q quit")
669
+ if self.query:
670
+ hints = f"filter:/{self.query} " + hints
671
+ self._put(stdscr, y, 0, hints, self.attr("muted", curses.A_DIM))
672
+
673
+ # ---- viewport -----------------------------------------------------
674
+ def clamp_viewport(self, body_h):
675
+ if body_h <= 0:
676
+ return
677
+ if self.sel < self.top:
678
+ self.top = self.sel
679
+ elif self.sel >= self.top + body_h:
680
+ self.top = self.sel - body_h + 1
681
+ self.top = max(0, min(self.top, max(0, len(self.rows) - body_h)))
682
+
683
+ def move(self, delta, body_h):
684
+ if not self.rows:
685
+ return
686
+ self.sel = max(0, min(len(self.rows) - 1, self.sel + delta))
687
+
688
+ # ---- actions ------------------------------------------------------
689
+ def current(self):
690
+ if 0 <= self.sel < len(self.rows):
691
+ return self.rows[self.sel]
692
+ return None
693
+
694
+ def toggle_group(self, name):
695
+ if name in self.collapsed:
696
+ self.collapsed.discard(name)
697
+ else:
698
+ self.collapsed.add(name)
699
+ self.rebuild_rows()
700
+
701
+ def toggle_all(self):
702
+ if len(self.collapsed) < len(self.groups):
703
+ self.collapsed = set(self.groups)
704
+ else:
705
+ self.collapsed.clear()
706
+ self.rebuild_rows()
707
+
708
+ def show_help(self, stdscr):
709
+ pager = shutil.which("less") or os.environ.get("PAGER", "less")
710
+ fd, tmp = tempfile.mkstemp(suffix=".txt", prefix="tix-help-")
711
+ os.close(fd)
712
+ tmp_path = Path(tmp)
713
+ try:
714
+ tmp_path.write_text(HELP_TEXT, encoding="utf-8")
715
+ curses.def_prog_mode()
716
+ curses.endwin()
717
+ try:
718
+ subprocess.run([pager, str(tmp_path)])
719
+ except OSError:
720
+ pass
721
+ curses.reset_prog_mode()
722
+ stdscr.refresh()
723
+ finally:
724
+ try:
725
+ tmp_path.unlink()
726
+ except OSError:
727
+ pass
728
+
729
+ def open_ticket(self, stdscr, ticket):
730
+ pager = shutil.which("glow")
731
+ cmd = [pager, "-p", str(ticket.path)] if pager else \
732
+ [os.environ.get("PAGER", "less"), str(ticket.path)]
733
+ curses.def_prog_mode()
734
+ curses.endwin()
735
+ try:
736
+ subprocess.run(cmd)
737
+ except Exception:
738
+ pass
739
+ curses.reset_prog_mode()
740
+ stdscr.refresh()
741
+
742
+ def open_url(self, ticket):
743
+ if not ticket.url:
744
+ return
745
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
746
+ if not shutil.which(opener):
747
+ return
748
+ try:
749
+ subprocess.Popen([opener, ticket.url],
750
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
751
+ except Exception:
752
+ pass
753
+
754
+ def reload(self):
755
+ keep = self.selected_path()
756
+ self.tickets = load_tickets()
757
+ self.lanes = load_lanes()
758
+ self._dir_sig = dir_signature()
759
+ self.rebuild()
760
+ if keep:
761
+ self.reselect_path(keep)
762
+
763
+ def selected_path(self):
764
+ row = self.current()
765
+ if row and row["type"] == "ticket":
766
+ return row["ticket"].path
767
+ return None
768
+
769
+ def reselect_path(self, path):
770
+ for i, row in enumerate(self.rows):
771
+ if row["type"] == "ticket" and row["ticket"].path == path:
772
+ self.sel = i
773
+ return
774
+
775
+ # ---- external dispatch -------------------------------------------
776
+ @staticmethod
777
+ def in_tmux():
778
+ return bool(os.environ.get("TMUX"))
779
+
780
+ def run_external(self, stdscr, argv, name=None):
781
+ """Run an external command. In tmux: spawn a new window so tix keeps
782
+ running. Otherwise: suspend curses, run in the foreground, restore.
783
+ argv is a list — no shell interpolation, free-text safe."""
784
+ if self.in_tmux() and shutil.which("tmux"):
785
+ quoted = " ".join(shlex.quote(a) for a in argv)
786
+ cmd = ["tmux", "new-window"]
787
+ if name:
788
+ cmd += ["-n", name]
789
+ cmd.append(quoted)
790
+ try:
791
+ subprocess.run(cmd, check=False)
792
+ except OSError:
793
+ pass
794
+ return
795
+ curses.def_prog_mode()
796
+ curses.endwin()
797
+ try:
798
+ subprocess.run(argv)
799
+ except OSError:
800
+ pass
801
+ curses.reset_prog_mode()
802
+ stdscr.refresh()
803
+
804
+ def capture_buffer(self, stdscr, seed=""):
805
+ """Open $EDITOR on a tmpfile (pre-seeded). Return stripped contents.
806
+ Empty string = user cleared / aborted — caller should noop."""
807
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
808
+ fd, tmp_path = tempfile.mkstemp(suffix=".md", prefix="tix-")
809
+ os.close(fd)
810
+ tmp = Path(tmp_path)
811
+ try:
812
+ if seed:
813
+ tmp.write_text(seed, encoding="utf-8")
814
+ curses.def_prog_mode()
815
+ curses.endwin()
816
+ try:
817
+ subprocess.run([editor, str(tmp)])
818
+ except OSError:
819
+ pass
820
+ curses.reset_prog_mode()
821
+ stdscr.refresh()
822
+ try:
823
+ return tmp.read_text(encoding="utf-8").strip()
824
+ except OSError:
825
+ return ""
826
+ finally:
827
+ try:
828
+ tmp.unlink()
829
+ except OSError:
830
+ pass
831
+
832
+ # ---- ticket actions ----------------------------------------------
833
+ def pickup_ticket(self, stdscr, ticket):
834
+ """Foreground-suspend curses and hand off to `wt`. The flow mirrors a
835
+ manual pickup: fetch + check out main + fast-forward + spawn lane.
836
+ `wt` itself opens the lane in its own tmux window per WT_LAYOUT and
837
+ returns — wrapping that in our own `tmux new-window` would flash an
838
+ extra window before wt's real one."""
839
+ wt = shutil.which("wt") or "wt"
840
+ curses.def_prog_mode()
841
+ curses.endwin()
842
+ try:
843
+ # Confirm cwd is a git repo — else wt would fail silently and no
844
+ # lane window would spawn, which is the exact symptom we're fixing.
845
+ in_repo = subprocess.run(
846
+ ["git", "rev-parse", "--show-toplevel"],
847
+ capture_output=True, text=True,
848
+ )
849
+ if in_repo.returncode != 0:
850
+ print("tix: cwd is not a git repo — run tix from a repo root.")
851
+ input("press enter to return…")
852
+ else:
853
+ # /pickup-style base sync: fetch + checkout main + ff merge.
854
+ # Each step is best-effort; wt still runs even on partial sync
855
+ # so a transient fetch failure doesn't block the lane.
856
+ subprocess.run(["git", "fetch", "--quiet", "origin"])
857
+ subprocess.run(["git", "checkout", "main"])
858
+ subprocess.run(["git", "merge", "--ff-only", "origin/main"])
859
+ subprocess.run([wt, ticket.slug])
860
+ except OSError:
861
+ pass
862
+ curses.reset_prog_mode()
863
+ stdscr.refresh()
864
+
865
+ def edit_brief(self, stdscr, ticket):
866
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
867
+ curses.def_prog_mode()
868
+ curses.endwin()
869
+ try:
870
+ subprocess.run([editor, str(ticket.path)])
871
+ except OSError:
872
+ pass
873
+ curses.reset_prog_mode()
874
+ stdscr.refresh()
875
+ self.reload()
876
+
877
+ def rescope_ticket(self, stdscr, ticket):
878
+ seed = (f"# Rescope notes for `{ticket.slug}` — claude reads everything below.\n"
879
+ f"# Lines beginning with # are passed through; delete them if you don't\n"
880
+ f"# want them sent. Save & quit to dispatch, or leave empty to cancel.\n\n")
881
+ text = self.capture_buffer(stdscr, seed=seed)
882
+ if not text:
883
+ return
884
+ prompt = f"/rescope {ticket.slug} {text}"
885
+ self.run_external(stdscr, ["claude", prompt], name=f"rescope:{ticket.slug[:10]}")
886
+
887
+ def new_ticket(self, stdscr, seed=""):
888
+ if not seed:
889
+ seed = ("# New ticket — describe the problem. Claude will run /scope on this\n"
890
+ "# text: it'll ask up to 3 clarifying questions, then engineer the brief.\n"
891
+ "# Save & quit to dispatch, or leave empty to cancel.\n\n")
892
+ text = self.capture_buffer(stdscr, seed=seed)
893
+ if not text:
894
+ return
895
+ prompt = f"/scope {text}"
896
+ self.run_external(stdscr, ["claude", prompt], name="scope")
897
+
898
+ def new_from_clipboard(self, stdscr):
899
+ clip = ""
900
+ clip_cmd = "pbpaste" if sys.platform == "darwin" else "xclip"
901
+ if shutil.which(clip_cmd):
902
+ args = [clip_cmd] if clip_cmd == "pbpaste" else [clip_cmd, "-o", "-selection", "clipboard"]
903
+ try:
904
+ clip = subprocess.run(args, capture_output=True, text=True,
905
+ timeout=5).stdout
906
+ except (OSError, subprocess.SubprocessError):
907
+ clip = ""
908
+ seed = ("# New ticket from clipboard paste (Granola, notes, etc).\n"
909
+ "# Trim or annotate — claude will /scope this. Empty = cancel.\n\n")
910
+ seed += clip
911
+ self.new_ticket(stdscr, seed=seed)
912
+
913
+ def toggle_cancel(self, ticket):
914
+ """Flip cancelled ↔ open in place — no confirm prompt. Cancelled is
915
+ sticky in the reconciler, so the write survives subsequent syncs."""
916
+ new_status = "open" if ticket.status in CANCELLED_STATUSES else "cancelled"
917
+ write_status(ticket.path, new_status)
918
+ ticket.status = new_status
919
+ path = ticket.path
920
+ self.rebuild()
921
+ self.reselect_path(path)
922
+
923
+ def move_ticket(self, ticket, new_area):
924
+ """Move a single area-level brief to a different area folder. Uses
925
+ `git mv` when the tree is a git repo so history follows; falls back
926
+ to plain rename when it isn't. Also rewrites the stored `area:`
927
+ frontmatter so it matches the new location."""
928
+ if ticket.area == new_area:
929
+ return
930
+ src = ticket.path
931
+ dest_dir = TICKETS_DIR / new_area
932
+ dest = dest_dir / src.name
933
+ if dest.exists():
934
+ return # slug collision in target area — bail rather than clobber
935
+ dest_dir.mkdir(parents=True, exist_ok=True)
936
+ moved = False
937
+ try:
938
+ result = subprocess.run(
939
+ ["git", "-C", str(TICKETS_DIR), "mv",
940
+ str(src.relative_to(TICKETS_DIR)),
941
+ str(dest.relative_to(TICKETS_DIR))],
942
+ capture_output=True, text=True, timeout=10,
943
+ )
944
+ moved = result.returncode == 0
945
+ except (OSError, subprocess.SubprocessError):
946
+ moved = False
947
+ if not moved:
948
+ try:
949
+ src.rename(dest)
950
+ moved = True
951
+ except OSError:
952
+ return
953
+ write_frontmatter_field(dest, "area", new_area)
954
+ self.reload()
955
+ self.reselect_path(dest)
956
+
957
+ def toggle_done(self, ticket):
958
+ """Flip done ↔ open in place. Sticky in the reconciler so a manual
959
+ mark survives even without a merged PR — useful for spikes, ops, or
960
+ research tickets whose 'completion' has no PR signal."""
961
+ new_status = "open" if ticket.status.lower() == "done" else "done"
962
+ write_status(ticket.path, new_status)
963
+ ticket.status = new_status
964
+ path = ticket.path
965
+ self.rebuild()
966
+ self.reselect_path(path)
967
+
968
+ def toggle_inprogress(self, ticket):
969
+ """Flip active ↔ open in place. Sticky in the reconciler so a manual
970
+ mark survives without a live worktree — useful when work is happening
971
+ outside a `wt` lane (direct branch checkout, paired work, etc)."""
972
+ new_status = "open" if ticket.status.lower() == "active" else "active"
973
+ write_status(ticket.path, new_status)
974
+ ticket.status = new_status
975
+ path = ticket.path
976
+ self.rebuild()
977
+ self.reselect_path(path)
978
+
979
+ def bump_priority(self, ticket, delta):
980
+ """delta > 0 raises priority (toward P0); delta < 0 lowers it toward
981
+ cleared. Writes frontmatter, then rebuilds so the new sort takes."""
982
+ seq = [""] + PRIORITY_ORDER
983
+ idx = (PRIORITY_ORDER.index(ticket.priority) + 1
984
+ if ticket.priority in PRIORITY_ORDER else 0)
985
+ new_idx = max(0, min(len(seq) - 1, idx - delta))
986
+ new_pri = seq[new_idx]
987
+ if new_pri == ticket.priority:
988
+ return
989
+ write_priority(ticket.path, new_pri)
990
+ ticket.priority = new_pri
991
+ path = ticket.path
992
+ self.rebuild()
993
+ self.reselect_path(path)
994
+
995
+ # ---- main loop ----------------------------------------------------
996
+ def run(self, stdscr):
997
+ curses.curs_set(0)
998
+ stdscr.keypad(True)
999
+ # 2 s idle timeout so getch() periodically returns -1 even with no
1000
+ # keystroke — we use that tick to detect external writes (claude /scope
1001
+ # finishing in a tmux window, sync.py running, hand edits) and reload.
1002
+ stdscr.timeout(2000)
1003
+ self.init_colors()
1004
+ while True:
1005
+ h, _ = stdscr.getmaxyx()
1006
+ body_h = max(1, h - 2)
1007
+ curses.curs_set(1 if self.search_mode else 0)
1008
+ self.draw(stdscr)
1009
+ ch = stdscr.getch()
1010
+ if ch == -1:
1011
+ new_sig = dir_signature()
1012
+ if new_sig != self._dir_sig:
1013
+ self.reload()
1014
+ continue
1015
+ if self.move_mode is not None:
1016
+ ticket = self.move_mode
1017
+ if ch == 27: # esc — cancel
1018
+ self.move_mode = None
1019
+ elif ord("1") <= ch <= ord("9"):
1020
+ idx = ch - ord("1")
1021
+ self.move_mode = None
1022
+ if idx < len(AREAS):
1023
+ self.move_ticket(ticket, AREAS[idx])
1024
+ continue
1025
+ if self.search_mode:
1026
+ self.handle_search_key(ch)
1027
+ continue
1028
+ if ch in (ord("q"), 27):
1029
+ return
1030
+ elif ch in (curses.KEY_DOWN, ord("j")):
1031
+ self.move(1, body_h)
1032
+ elif ch in (curses.KEY_UP, ord("k")):
1033
+ self.move(-1, body_h)
1034
+ elif ch == curses.KEY_NPAGE or ch == 4: # PgDn / Ctrl-D
1035
+ self.move(body_h // 2 if ch == 4 else body_h, body_h)
1036
+ elif ch == curses.KEY_PPAGE or ch == 21: # PgUp / Ctrl-U
1037
+ self.move(-(body_h // 2) if ch == 21 else -body_h, body_h)
1038
+ elif ch == ord("g"):
1039
+ self.sel = 0
1040
+ elif ch == ord("G"):
1041
+ self.sel = max(0, len(self.rows) - 1)
1042
+ elif ch in (curses.KEY_ENTER, 10, 13, curses.KEY_RIGHT, ord("l")):
1043
+ self.activate(stdscr)
1044
+ elif ch == ord(" "):
1045
+ row = self.current()
1046
+ if row:
1047
+ name = row["group"] if row["type"] == "group" else row["ticket"].group
1048
+ self.toggle_group(name)
1049
+ elif ch in (curses.KEY_LEFT, ord("h")):
1050
+ row = self.current()
1051
+ if row and row["type"] == "ticket":
1052
+ self.toggle_group(row["ticket"].group)
1053
+ elif row and row["type"] == "group" and row["group"] not in self.collapsed:
1054
+ self.toggle_group(row["group"])
1055
+ elif ch == ord("\t"):
1056
+ self.filter_idx = (self.filter_idx + 1) % len(self.filters)
1057
+ self.rebuild_rows()
1058
+ elif ch == curses.KEY_BTAB:
1059
+ self.filter_idx = (self.filter_idx - 1) % len(self.filters)
1060
+ self.rebuild_rows()
1061
+ elif ord("1") <= ch <= ord("9"):
1062
+ i = ch - ord("1")
1063
+ if i < len(self.filters):
1064
+ self.filter_idx = i
1065
+ self.rebuild_rows()
1066
+ elif ch == ord("/"):
1067
+ self.search_mode = True
1068
+ elif ch == ord("o"):
1069
+ row = self.current()
1070
+ if row and row["type"] == "ticket":
1071
+ self.open_url(row["ticket"])
1072
+ elif ch == ord("r"):
1073
+ self.reload()
1074
+ elif ch == ord("p"):
1075
+ row = self.current()
1076
+ if row and row["type"] == "ticket":
1077
+ self.pickup_ticket(stdscr, row["ticket"])
1078
+ elif ch == ord("e"):
1079
+ row = self.current()
1080
+ if row and row["type"] == "ticket":
1081
+ self.edit_brief(stdscr, row["ticket"])
1082
+ elif ch == ord("R"):
1083
+ row = self.current()
1084
+ if row and row["type"] == "ticket":
1085
+ self.rescope_ticket(stdscr, row["ticket"])
1086
+ elif ch == ord("n"):
1087
+ self.new_ticket(stdscr)
1088
+ elif ch == ord("N"):
1089
+ self.new_from_clipboard(stdscr)
1090
+ elif ch in (ord("+"), ord("=")):
1091
+ row = self.current()
1092
+ if row and row["type"] == "ticket":
1093
+ self.bump_priority(row["ticket"], 1)
1094
+ elif ch == ord("-"):
1095
+ row = self.current()
1096
+ if row and row["type"] == "ticket":
1097
+ self.bump_priority(row["ticket"], -1)
1098
+ elif ch == ord("x"):
1099
+ row = self.current()
1100
+ if row and row["type"] == "ticket":
1101
+ self.toggle_cancel(row["ticket"])
1102
+ elif ch == ord("d"):
1103
+ row = self.current()
1104
+ if row and row["type"] == "ticket":
1105
+ self.toggle_done(row["ticket"])
1106
+ elif ch == ord("i"):
1107
+ row = self.current()
1108
+ if row and row["type"] == "ticket":
1109
+ self.toggle_inprogress(row["ticket"])
1110
+ elif ch == ord("m"):
1111
+ row = self.current()
1112
+ if row and row["type"] == "ticket":
1113
+ t = row["ticket"]
1114
+ # Only area-level briefs (parent is the area dir). Epic
1115
+ # children stay with their epic; epic folders need their
1116
+ # whole tree moved, which is out of scope for now.
1117
+ if not t.is_epic and t.path.parent.parent == TICKETS_DIR:
1118
+ self.move_mode = t
1119
+ elif ch == ord("?"):
1120
+ self.show_help(stdscr)
1121
+ elif ch in (ord("C"), ord("z")):
1122
+ self.toggle_all()
1123
+
1124
+ def activate(self, stdscr):
1125
+ row = self.current()
1126
+ if not row:
1127
+ return
1128
+ if row["type"] == "group":
1129
+ self.toggle_group(row["group"])
1130
+ else:
1131
+ self.open_ticket(stdscr, row["ticket"])
1132
+
1133
+ def handle_search_key(self, ch):
1134
+ if ch in (27,): # ESC — cancel
1135
+ self.search_mode = False
1136
+ self.query = ""
1137
+ self.rebuild_rows()
1138
+ elif ch in (curses.KEY_ENTER, 10, 13): # commit
1139
+ self.search_mode = False
1140
+ self.rebuild_rows()
1141
+ elif ch in (curses.KEY_BACKSPACE, 127, 8):
1142
+ self.query = self.query[:-1]
1143
+ self.rebuild_rows()
1144
+ elif 32 <= ch < 127:
1145
+ self.query += chr(ch)
1146
+ self.rebuild_rows()
1147
+
1148
+
1149
+ def run_preload_hook():
1150
+ """Run $TIX_PRELOAD_HOOK as a shell command before the TUI takes over.
1151
+
1152
+ tix is a pure reader — it never writes `status:` frontmatter. Users who
1153
+ want status auto-derived (from worktrees, branches, merged PRs, etc.)
1154
+ point this env var at their own reconciler script. Output is captured:
1155
+ tix is about to claim the terminal with curses, so any printed diff
1156
+ would be wiped anyway. Best-effort — unset/missing/erroring hook never
1157
+ blocks launch."""
1158
+ hook = os.environ.get("TIX_PRELOAD_HOOK")
1159
+ if not hook:
1160
+ return
1161
+ try:
1162
+ subprocess.run(
1163
+ hook, shell=True, capture_output=True, timeout=30,
1164
+ )
1165
+ except (OSError, subprocess.SubprocessError):
1166
+ pass
1167
+
1168
+
1169
+ def main():
1170
+ if not TICKETS_DIR.is_dir():
1171
+ print(f"tix: no ticket directory at {TICKETS_DIR}", file=sys.stderr)
1172
+ return 1
1173
+ run_preload_hook()
1174
+ app = App()
1175
+ if not app.tickets:
1176
+ print(f"tix: no tickets found under {TICKETS_DIR}", file=sys.stderr)
1177
+ return 1
1178
+ curses.wrapper(app.run)
1179
+ return 0
1180
+
1181
+
1182
+ if __name__ == "__main__":
1183
+ sys.exit(main())