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/__init__.py +3 -0
- tix/__main__.py +29 -0
- tix/templates/_CHILD-TEMPLATE.md +20 -0
- tix/templates/_EPIC-TEMPLATE.md +42 -0
- tix/templates/_TEMPLATE.md +33 -0
- tix/tui.py +1183 -0
- tix_cli-0.1.0.dist-info/METADATA +191 -0
- tix_cli-0.1.0.dist-info/RECORD +11 -0
- tix_cli-0.1.0.dist-info/WHEEL +4 -0
- tix_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tix_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|