trigger-tree 1.14.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.
@@ -0,0 +1,1013 @@
1
+ #!/usr/bin/env python3
2
+ """trigger-tree live watcher — colored ASCII pulse animation over the docs tree.
3
+
4
+ Run in a second terminal pane next to a Claude Code session:
5
+
6
+ python3 scripts/tt-watch.py # live: tails .trigger-tree/history.jsonl
7
+ python3 scripts/tt-watch.py --demo # synthetic events, writes nothing
8
+ python3 scripts/tt-watch.py --replay # replays the real history, accelerated
9
+
10
+ A read makes its file flash white and ripples a pulse up through its parent
11
+ folders, then fades back to the file's time-decayed heat color. Lifetime read
12
+ counts remain visible as separate evidence. Untouched
13
+ paths stay dim gray. Cold-to-hot activity uses a coherent blue → cyan → green →
14
+ amber → red spectrum. Quit with q or Ctrl+C. 256-color ANSI, stdlib only.
15
+ """
16
+
17
+ import argparse
18
+ import glob as globmod
19
+ import importlib.util
20
+ import json
21
+ import math
22
+ import os
23
+ import random
24
+ import re
25
+ import select
26
+ import shutil
27
+ import sys
28
+ import tempfile
29
+ import time
30
+ import unicodedata
31
+ from collections import Counter, deque
32
+ from datetime import datetime, timezone
33
+
34
+ ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
35
+ HIST = os.path.join(ROOT, ".trigger-tree", "history.jsonl")
36
+
37
+ if os.name == "nt": # pragma: no cover — Windows console setup
38
+ os.system("") # enables ANSI escape processing in the Windows terminal
39
+ try:
40
+ sys.stdout.reconfigure(encoding="utf-8")
41
+ except AttributeError: # pragma: no cover — exotic stdout replacement
42
+ pass
43
+
44
+ # Layered config: project override → plugin default → hardcoded. A partial or
45
+ # broken config.sh must never crash the watcher.
46
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
47
+
48
+
49
+ def _conf_texts():
50
+ texts = []
51
+ for path in (
52
+ os.path.join(ROOT, ".trigger-tree", "config.sh"),
53
+ os.path.join(SCRIPT_DIR, "tt-config.sh"),
54
+ ):
55
+ try:
56
+ texts.append(open(path, encoding="utf-8").read())
57
+ except OSError:
58
+ continue
59
+ return texts
60
+
61
+
62
+ def _conf_regex(name, fallback):
63
+ for text in _conf_texts():
64
+ m = re.search(name + r"='([^']+)'", text)
65
+ if m:
66
+ try:
67
+ return re.compile(m.group(1))
68
+ except re.error:
69
+ continue
70
+ return re.compile(fallback)
71
+
72
+
73
+ WATCH = _conf_regex(
74
+ "TT_WATCH_REGEX",
75
+ r"^(docs|agents|skills|agent-briefs)/.*\.md$|^\.claude/(rules|skills)/.*\.md$|"
76
+ r"^(CLAUDE|AGENTS|GEMINI)\.md$",
77
+ )
78
+ ALWAYS_LOADED = _conf_regex(
79
+ "TT_ALWAYS_LOADED_REGEX",
80
+ r"(^|/)(CLAUDE(?:\.local)?|AGENTS|GEMINI)\.md$|^\.claude/(rules|skills)/",
81
+ )
82
+ BASES = ["docs", "agents", "skills", "agent-briefs", ".claude/rules", ".claude/skills", "."]
83
+
84
+ SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
85
+ COLD, COOL, GREEN, AMBER, RED = 75, 80, 114, 214, 196
86
+ DEAD, DIM, WHITE = 240, 245, 231
87
+ BUCKET_LIMIT = 20 # detailed per-prompt buckets kept for browsing (totals aggregate all)
88
+ EVENTS_PER_BUCKET = 500 # cap against runaway tasks flooding one bucket
89
+ LIVE_FOLDER_LIMIT = 10 # focused live overview; full cold inventory lives in insights
90
+ ESCAPE_BYTE_TIMEOUT = 0.2 # tolerate delayed terminal bytes on loaded machines
91
+ PULSE_SECS = 1.4 # how long a flash takes to fade
92
+ RIPPLE_DELAY = 0.09 # per tree level, leaf → root
93
+ RECENT_SECS = 8.0 # keep recently active folders visible before alpha fallback
94
+ INVENTORY_SYNC_SECS = 5.0 # disk discovery is useful, but a full walk need not run per second
95
+ HEAT_HALF_LIFE_DAYS = 30.0
96
+ HEAT_DEAD_THRESHOLD = 0.05
97
+ INJECTED = 141
98
+ TIP_ROTATE_SECS = 30.0
99
+
100
+
101
+ def prompt_mode():
102
+ for text in _conf_texts():
103
+ match = re.search(r"TT_LOG_PROMPTS='(hash|truncate|off)'", text)
104
+ if match:
105
+ return match.group(1)
106
+ return "truncate"
107
+
108
+
109
+ def save_prompt_mode(mode):
110
+ """Atomically update only prompt privacy in the project config."""
111
+ if mode not in ("truncate", "hash", "off"):
112
+ return False
113
+ directory = os.path.join(ROOT, ".trigger-tree")
114
+ config = os.path.join(directory, "config.sh")
115
+ try:
116
+ if os.path.lexists(directory) and os.path.islink(directory):
117
+ return False
118
+ os.makedirs(directory, mode=0o700, exist_ok=True)
119
+ if os.path.lexists(config) and (os.path.islink(config) or not os.path.isfile(config)):
120
+ return False
121
+ try:
122
+ text = open(config, encoding="utf-8").read()
123
+ except FileNotFoundError:
124
+ text = ""
125
+ line = f"TT_LOG_PROMPTS='{mode}'"
126
+ if re.search(r"^TT_LOG_PROMPTS='[^']*'", text, flags=re.M):
127
+ text = re.sub(r"^TT_LOG_PROMPTS='[^']*'", line, text, flags=re.M)
128
+ else:
129
+ text = text.rstrip("\n") + ("\n" if text else "") + line + "\n"
130
+ fd, temporary = tempfile.mkstemp(prefix=".config.", dir=directory, text=True)
131
+ try:
132
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
133
+ handle.write(text)
134
+ os.chmod(temporary, 0o600)
135
+ os.replace(temporary, config)
136
+ finally:
137
+ if os.path.exists(temporary):
138
+ os.unlink(temporary)
139
+ return True
140
+ except OSError:
141
+ return False
142
+
143
+
144
+ def terminal_safe(value):
145
+ """Strip terminal controls and invisible direction overrides from untrusted text."""
146
+ bidi_controls = "\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069"
147
+ text = re.sub(r"(?:\x1b\]|\x9d).*?(?:\x07|\x1b\\|\x9c)", "", str(value), flags=re.S)
148
+ text = re.sub(r"(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]", "", text)
149
+ text = re.sub(r"\x1b[@-_]", "", text)
150
+ return "".join(
151
+ ch
152
+ for ch in text
153
+ if unicodedata.category(ch) not in ("Cc", "Cs") and ch not in bidi_controls
154
+ )
155
+
156
+
157
+ def detect_client(explicit="auto"):
158
+ if explicit in ("claude", "codex"):
159
+ return explicit
160
+ if os.environ.get("CODEX_HOME") or os.environ.get("PLUGIN_ROOT"):
161
+ return "codex"
162
+ if os.environ.get("CLAUDE_PLUGIN_ROOT"):
163
+ return "claude"
164
+ return None
165
+
166
+
167
+ def load_tips(client):
168
+ if client not in ("claude", "codex"):
169
+ return []
170
+ path = os.path.join(SCRIPT_DIR, "tt-tips.py")
171
+ try:
172
+ spec = importlib.util.spec_from_file_location("trigger_tree_tips", path)
173
+ module = importlib.util.module_from_spec(spec)
174
+ spec.loader.exec_module(module)
175
+ return module.tips_for(client, ROOT)
176
+ except (AttributeError, OSError, ImportError):
177
+ return []
178
+
179
+
180
+ def c256(n, text, bold=False):
181
+ return f"\x1b[{'1;' if bold else ''}38;5;{n}m{terminal_safe(text)}\x1b[0m"
182
+
183
+
184
+ def inventory():
185
+ seen = set()
186
+ for base in BASES:
187
+ top = os.path.join(ROOT, base)
188
+ if not os.path.isdir(top):
189
+ continue
190
+ walker = os.walk(top) if base != "." else [(top, [], os.listdir(top))]
191
+ for dirpath, _, files in walker:
192
+ for f in files:
193
+ rel = os.path.relpath(os.path.join(dirpath, f), ROOT).replace(os.sep, "/")
194
+ if WATCH.search(rel):
195
+ seen.add(rel)
196
+ return sorted(seen)
197
+
198
+
199
+ def all_history_files():
200
+ return sorted(globmod.glob(os.path.join(ROOT, ".trigger-tree", "history*.jsonl")))
201
+
202
+
203
+ def timestamp_epoch(ts):
204
+ try:
205
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
206
+ except (TypeError, ValueError):
207
+ return None
208
+
209
+
210
+ class Tail:
211
+ """Follow history.jsonl; survives the file not existing yet."""
212
+
213
+ def __init__(self, path, from_start=False):
214
+ self.path = path
215
+ self.pos = 0
216
+ self.pending = b""
217
+ if not from_start and os.path.isfile(path):
218
+ self.pos = os.path.getsize(path)
219
+
220
+ def poll(self):
221
+ if not os.path.isfile(self.path):
222
+ return []
223
+ size = os.path.getsize(self.path)
224
+ if size < self.pos: # truncated/rotated
225
+ self.pos = 0
226
+ self.pending = b""
227
+ if size == self.pos:
228
+ return []
229
+ with open(self.path, "rb") as fh:
230
+ fh.seek(self.pos)
231
+ chunk = fh.read()
232
+ self.pos = fh.tell()
233
+ chunk = self.pending + chunk
234
+ lines = chunk.split(b"\n")
235
+ self.pending = lines.pop() # an unterminated append is retried on the next poll
236
+ events = []
237
+ for line in lines:
238
+ try:
239
+ events.append(json.loads(line.decode("utf-8")))
240
+ except (UnicodeDecodeError, json.JSONDecodeError):
241
+ continue
242
+ return events
243
+
244
+
245
+ def iter_all_events():
246
+ """Stream archived history in order without retaining a second full copy."""
247
+ for path in all_history_files():
248
+ yield from Tail(path, from_start=True).poll()
249
+
250
+
251
+ def load_all_events():
252
+ """Materialize history only for accelerated replay, which needs indexing."""
253
+ return list(iter_all_events())
254
+
255
+
256
+ class App:
257
+ def __init__(self, files, tips=None):
258
+ self.files = list(files)
259
+ self.counts = Counter()
260
+ # path -> (decayed score at reference timestamp, reference timestamp).
261
+ # This preserves exponential heat without retaining every historical read.
262
+ self.read_heat = {}
263
+ self.scans = Counter()
264
+ self.pulses = {} # node path -> pulse start time (may lie in the future)
265
+ self.ticker = deque(maxlen=4)
266
+ self.total_reads = 0
267
+ self.total_scans = 0
268
+ self.total_skills = 0
269
+ self.total_prompts = 0
270
+ self.sessions = set()
271
+ self.last_event = None # wall-clock of the last live-fed event
272
+ self.buckets = [] # one bucket per typed prompt: its aggregated events
273
+ self._current = {} # session -> active bucket
274
+ self.selected = None # bucket index while browsing, None = live view
275
+ self.sort_mode = "focus" # focus | hot | cold | name
276
+ self.name_desc = False
277
+ self.settings_open = False
278
+ self.settings_message = ""
279
+ self.tips = list(tips or [])
280
+
281
+ def sync_inventory(self, files):
282
+ """Make the live tree reflect disk; historical counters remain intact."""
283
+ self.files = sorted(set(files))
284
+
285
+ def _trim_buckets(self):
286
+ """Bound both browseable buckets and per-session references to them."""
287
+ if len(self.buckets) <= BUCKET_LIMIT:
288
+ return
289
+ evicted_count = len(self.buckets) - BUCKET_LIMIT
290
+ evicted = self.buckets[:evicted_count]
291
+ del self.buckets[:evicted_count]
292
+ for bucket in evicted:
293
+ session = bucket["session"]
294
+ if self._current.get(session) is bucket:
295
+ del self._current[session]
296
+ if self.selected is not None:
297
+ self.selected = max(0, self.selected - evicted_count)
298
+
299
+ def feed(self, ev, live=True):
300
+ t = ev.get("t")
301
+ if live:
302
+ self.last_event = time.time()
303
+ s_id = ev.get("session", "?")
304
+ self.sessions.add(s_id)
305
+ if t == "prompt":
306
+ prompt_text = (ev.get("prompt") or "").strip()
307
+ prompt_hash = (ev.get("prompt_hash") or "").strip()
308
+ bucket = {
309
+ "session": s_id,
310
+ "ts": ev.get("ts", ""),
311
+ "prompt": prompt_text
312
+ or (f"#{prompt_hash}" if prompt_hash else "(prompt text off)"),
313
+ "prompt_kind": "text" if prompt_text else "hash" if prompt_hash else "off",
314
+ "events": [],
315
+ }
316
+ self.buckets.append(bucket)
317
+ self._current[s_id] = bucket
318
+ self.total_prompts += 1
319
+ self._trim_buckets()
320
+ if live: # a typed prompt is instantly visible: the pipeline is alive
321
+ txt = bucket["prompt"][:44] + ("…" if len(bucket["prompt"]) > 44 else "")
322
+ self.ticker.appendleft((time.time(), "▸", f'"{txt}"', "prompt"))
323
+ elif t in ("read", "scan", "skill"):
324
+ bucket = self._current.get(s_id)
325
+ if bucket is None:
326
+ bucket = {
327
+ "session": s_id,
328
+ "ts": ev.get("ts", ""),
329
+ "prompt": "(session start)",
330
+ "prompt_kind": "synthetic",
331
+ "events": [],
332
+ }
333
+ self.buckets.append(bucket)
334
+ self._current[s_id] = bucket
335
+ self._trim_buckets()
336
+ bucket["events"].append(
337
+ {
338
+ "t": t,
339
+ "ts": ev.get("ts", ""),
340
+ "path": ev.get("path") or f"skill:{ev.get('skill', '?')}",
341
+ "agent": ev.get("agent", "main"),
342
+ }
343
+ )
344
+ if len(bucket["events"]) > EVENTS_PER_BUCKET:
345
+ del bucket["events"][: len(bucket["events"]) - EVENTS_PER_BUCKET]
346
+ if t == "read":
347
+ path = ev["path"]
348
+ self.counts[path] += 1
349
+ timestamp = timestamp_epoch(ev.get("ts"))
350
+ if timestamp is None and live:
351
+ timestamp = time.time()
352
+ if timestamp is not None:
353
+ self._record_heat(path, timestamp)
354
+ self.total_reads += 1
355
+ if path not in self.files and os.path.isfile(os.path.join(ROOT, path)):
356
+ self.files.append(path)
357
+ if live:
358
+ self._pulse(path)
359
+ self.ticker.appendleft((time.time(), "●", path, ev.get("agent", "main")))
360
+ elif t == "scan":
361
+ self.scans[ev["path"]] += 1
362
+ self.total_scans += 1
363
+ if live:
364
+ self._pulse(ev["path"])
365
+ self.ticker.appendleft((time.time(), "🔍", ev["path"], ev.get("agent", "main")))
366
+ elif t == "skill":
367
+ self.total_skills += 1
368
+ if live:
369
+ skill_file = f".claude/skills/{ev.get('skill', '')}/SKILL.md"
370
+ if skill_file in self.files:
371
+ self._pulse(skill_file)
372
+ self.ticker.appendleft(
373
+ (time.time(), "⚡", f"skill:{ev.get('skill', '?')}", ev.get("agent", "main"))
374
+ )
375
+
376
+ def _pulse(self, path):
377
+ now = time.time()
378
+ parts = path.split("/")
379
+ # leaf flashes now, each ancestor a beat later: the ripple runs up the tree
380
+ for i in range(len(parts), 0, -1):
381
+ node = "/".join(parts[:i])
382
+ t0 = now + (len(parts) - i) * RIPPLE_DELAY
383
+ self.pulses[node] = max(self.pulses.get(node, 0), t0)
384
+
385
+ def _record_heat(self, path, timestamp):
386
+ half_life_seconds = HEAT_HALF_LIFE_DAYS * 86400
387
+ previous = self.read_heat.get(path)
388
+ if previous is None:
389
+ self.read_heat[path] = (1.0, timestamp)
390
+ return
391
+ score, reference = previous
392
+ if timestamp >= reference:
393
+ score *= 0.5 ** ((timestamp - reference) / half_life_seconds)
394
+ self.read_heat[path] = (score + 1.0, timestamp)
395
+ else:
396
+ # History should normally be chronological, but rotated/imported logs
397
+ # can be out of order. Add an older contribution at the current reference.
398
+ score += 0.5 ** ((reference - timestamp) / half_life_seconds)
399
+ self.read_heat[path] = (score, reference)
400
+
401
+ def select_prev(self):
402
+ if not self.buckets:
403
+ return
404
+ self.selected = (
405
+ len(self.buckets) - 1 if self.selected is None else max(0, self.selected - 1)
406
+ )
407
+
408
+ def select_next(self):
409
+ if not self.buckets:
410
+ return
411
+ # A bounded timeline: right always means newer. It never wraps or
412
+ # silently changes to the unrelated live overview (`a` does that).
413
+ if self.selected is None:
414
+ self.selected = len(self.buckets) - 1
415
+ else:
416
+ self.selected = min(len(self.buckets) - 1, self.selected + 1)
417
+
418
+ def select_live(self):
419
+ self.selected = None
420
+
421
+ def set_sort(self, mode):
422
+ if mode in ("focus", "hot", "cold", "name"):
423
+ if mode == "name" and self.sort_mode == "name":
424
+ self.name_desc = not self.name_desc
425
+ elif mode == "name":
426
+ self.name_desc = False
427
+ self.sort_mode = mode
428
+ self.selected = None
429
+
430
+ def set_prompt_mode(self, mode):
431
+ self.settings_message = (
432
+ f"Saved: {mode} (new prompts only)"
433
+ if save_prompt_mode(mode)
434
+ else "Could not safely update .trigger-tree/config.sh"
435
+ )
436
+
437
+ def _glow(self, node, now):
438
+ t0 = self.pulses.get(node)
439
+ if t0 is None or now < t0:
440
+ return 0.0
441
+ p = 1.0 - (now - t0) / PULSE_SECS
442
+ return max(0.0, p)
443
+
444
+ def heat_scores(self, now):
445
+ """Current attention per file; lifetime counts deliberately stay separate."""
446
+ half_life_seconds = HEAT_HALF_LIFE_DAYS * 86400
447
+ return Counter(
448
+ {
449
+ path: score * 0.5 ** (max(0.0, now - reference) / half_life_seconds)
450
+ for path, (score, reference) in self.read_heat.items()
451
+ }
452
+ )
453
+
454
+ def _heat(self, score):
455
+ if score < HEAT_DEAD_THRESHOLD:
456
+ return DEAD
457
+ if score < 0.5:
458
+ return COLD
459
+ if score < 2:
460
+ return COOL
461
+ if score < 5:
462
+ return GREEN
463
+ if score < 10:
464
+ return AMBER
465
+ return RED
466
+
467
+ def _node_color(self, base, glow):
468
+ if glow > 0.66:
469
+ return WHITE, True
470
+ if glow > 0.33:
471
+ return 229, True
472
+ return base, False
473
+
474
+ def _folder_sort_key(self, folder, now, browsing, activity=0):
475
+ """Prioritize live activity without making the tree permanently jumpy."""
476
+ if not folder or browsing:
477
+ return (folder != "", 1, 0, -activity, folder)
478
+ if self.sort_mode == "hot":
479
+ return (True, 0, 0, -activity, folder)
480
+ if self.sort_mode == "cold":
481
+ return (True, 0, 0, activity, folder)
482
+ if self.sort_mode == "name":
483
+ return (True, 0, 0, 0, folder)
484
+ touched = self.pulses.get(folder, 0)
485
+ recent = self._is_recent(folder, now)
486
+ return (True, 0 if recent else 1, -touched if recent else 0, -activity, folder)
487
+
488
+ def _is_recent(self, node, now):
489
+ """A scheduled ripple is active immediately, not only after it starts glowing."""
490
+ touched = self.pulses.get(node, 0)
491
+ return bool(touched and now - touched <= RECENT_SECS)
492
+
493
+ def _heat_bar(self, score, max_score, cells=5):
494
+ if score < HEAT_DEAD_THRESHOLD:
495
+ return "·" * cells
496
+ filled = max(1, round(cells * math.log1p(score) / math.log1p(max(max_score, 2))))
497
+ return "█" * min(cells, filled) + "·" * max(0, cells - filled)
498
+
499
+ def _sort_legend(self, width):
500
+ name_label = "Z–A" if self.name_desc else "A–Z"
501
+ if width < 75:
502
+ return f" sort:{self.sort_mode} · [f]ocus [h]ot [c]old [n]{name_label} [s]ettings"
503
+ return (
504
+ f" sort:{self.sort_mode} · [f] focus · [h] hot · [c] cold · "
505
+ f"[n] {name_label} · [s] settings"
506
+ )
507
+
508
+ def _heat_legend(self, width):
509
+ labels = (
510
+ (COLD, "cold"),
511
+ (COOL, "cool"),
512
+ (GREEN, "active"),
513
+ (AMBER, "warm"),
514
+ (RED, "hot"),
515
+ )
516
+ separator = "→" if width < 75 else " → "
517
+ scale = separator.join(c256(color, label, bold=True) for color, label in labels)
518
+ return c256(DIM, " heat: ") + scale + c256(DEAD, " · · untouched")
519
+
520
+ def _tip_line(self, now, width):
521
+ if not self.tips:
522
+ return None
523
+ tip = self.tips[int(now // TIP_ROTATE_SECS) % len(self.tips)]
524
+ room = max(12, width - 9)
525
+ if len(tip) > room:
526
+ tip = tip[: room - 1] + "…"
527
+ return c256(DIM, " tip: ") + c256(COOL, tip)
528
+
529
+ def render(self, now, width, height):
530
+ spin = SPINNER[int(now * 10) % len(SPINNER)]
531
+ header = [
532
+ c256(GREEN, f" {spin} ", bold=True)
533
+ + c256(WHITE, "trigger-tree", bold=True)
534
+ + c256(DIM, f" {os.path.basename(ROOT)} · live doc-discovery"),
535
+ "",
536
+ ]
537
+ if self.settings_open:
538
+ current = prompt_mode()
539
+ choices = (
540
+ ("1", "truncate", "local previews (first 200 characters)"),
541
+ ("2", "hash", "fingerprints only (repeat prompts remain linkable)"),
542
+ ("3", "off", "markers only (maximum privacy)"),
543
+ )
544
+ lines = header + [c256(WHITE, " Prompt privacy", bold=True), ""]
545
+ for key, mode, description in choices:
546
+ marker = "●" if mode == current else "○"
547
+ color = GREEN if mode == current else DIM
548
+ lines.append(c256(color, f" [{key}] {marker} {mode:<8} {description}"))
549
+ lines += [
550
+ "",
551
+ c256(AMBER, f" {self.settings_message}") if self.settings_message else "",
552
+ ]
553
+ lines.append(c256(DEAD, " s/Esc back · changes apply to future prompts only"))
554
+ return lines[:height]
555
+ browsing = self.selected is not None and 0 <= self.selected < len(self.buckets)
556
+ if browsing:
557
+ b = self.buckets[self.selected]
558
+ counts = Counter(e["path"] for e in b["events"] if e["t"] == "read")
559
+ scan_counts = Counter(e["path"].rstrip("/") for e in b["events"] if e["t"] == "scan")
560
+ b_scans = sum(scan_counts.values())
561
+ prompt_room = max(18, min(120, width - 46))
562
+ prompt_txt = b["prompt"][:prompt_room] + ("…" if len(b["prompt"]) > prompt_room else "")
563
+ privacy_hint = (
564
+ " · text hidden; set TT_LOG_PROMPTS='truncate' for future previews"
565
+ if b.get("prompt_kind") == "hash"
566
+ else ""
567
+ )
568
+ header.insert(
569
+ 1,
570
+ c256(AMBER, f" ▸ prompt {self.selected + 1}/{len(self.buckets)} ", bold=True)
571
+ + c256(WHITE, f'"{prompt_txt}"')
572
+ + c256(
573
+ DIM,
574
+ f" · {sum(counts.values())} reads · {b_scans} searches{privacy_hint}",
575
+ ),
576
+ )
577
+ files_src = sorted(counts)
578
+ heat_scores = Counter(counts)
579
+ else:
580
+ counts = Counter(
581
+ {path: count for path, count in self.counts.items() if path in self.files}
582
+ )
583
+ scan_counts = Counter({path.rstrip("/"): count for path, count in self.scans.items()})
584
+ files_src = self.files
585
+ heat_scores = Counter(
586
+ {path: score for path, score in self.heat_scores(now).items() if path in self.files}
587
+ )
588
+ max_heat = max(heat_scores.values(), default=0)
589
+
590
+ # group files per folder ("" = repo root)
591
+ folders = {}
592
+ for f in sorted(files_src):
593
+ folders.setdefault(os.path.dirname(f), []).append(f)
594
+ inventory_folders = {}
595
+ for f in self.files:
596
+ inventory_folders.setdefault(os.path.dirname(f), []).append(f)
597
+ # A scan-only prompt still needs a visible folder row. Search targets are
598
+ # directories in normal telemetry; a markdown target is filed by parent.
599
+ normalized_scans = Counter()
600
+ for target, count in scan_counts.items():
601
+ folder = os.path.dirname(target) if target.lower().endswith(".md") else target
602
+ if not browsing and not os.path.isdir(os.path.join(ROOT, folder)):
603
+ continue # deleted historical folders do not reappear in live view
604
+ normalized_scans[folder] += count
605
+ folders.setdefault(folder, [])
606
+
607
+ focus_summary = ""
608
+ folder_activity = {}
609
+ folder_heat = {}
610
+ for folder, files in folders.items():
611
+ # Injected instructions are context, not thermal evidence. They may
612
+ # remain visible in focus/name views but must not influence hot/cold
613
+ # folder ordering or make a cold inventory claim about themselves.
614
+ current_heat = sum(heat_scores[f] for f in files if not ALWAYS_LOADED.search(f))
615
+ folder_heat[folder] = current_heat
616
+ # Timestamp-less legacy reads have no honest heat. Keep their folder
617
+ # visible and deterministically ordered by lifetime count instead.
618
+ folder_activity[folder] = (
619
+ current_heat or sum(counts[f] for f in files)
620
+ ) + normalized_scans[folder]
621
+ folder_order = (
622
+ folder_heat if self.sort_mode in ("hot", "cold") and not browsing else folder_activity
623
+ )
624
+ if not browsing:
625
+ active = [
626
+ folder
627
+ for folder in folders
628
+ if any(counts[f] for f in folders[folder])
629
+ or normalized_scans[folder]
630
+ or self._is_recent(folder, now)
631
+ ]
632
+ if self.sort_mode in ("cold", "name"):
633
+ candidates = list(folders)
634
+ if self.sort_mode == "cold":
635
+ candidates = [
636
+ folder
637
+ for folder in candidates
638
+ if any(not ALWAYS_LOADED.search(path) for path in folders[folder])
639
+ ]
640
+ elif self.sort_mode == "hot":
641
+ candidates = [
642
+ folder for folder in folders if any(counts[f] for f in folders[folder])
643
+ ]
644
+ else:
645
+ candidates = active
646
+ candidates.sort(
647
+ key=lambda folder: self._folder_sort_key(folder, now, False, folder_order[folder])
648
+ )
649
+ if self.sort_mode == "name" and self.name_desc:
650
+ candidates.sort(key=lambda folder: (folder == "", folder), reverse=True)
651
+ shown_folders = set(candidates[:LIVE_FOLDER_LIMIT])
652
+ more_active = max(0, len(candidates) - len(shown_folders))
653
+ quiet = [folder for folder in folders if folder not in active]
654
+ quiet_unread = sum(len(inventory_folders.get(folder, [])) for folder in quiet)
655
+ folders = {
656
+ folder: files for folder, files in folders.items() if folder in shown_folders
657
+ }
658
+ summary = []
659
+ if more_active:
660
+ label = "more folders" if self.sort_mode in ("cold", "name") else "more active"
661
+ summary.append(f"{more_active} {label}")
662
+ if quiet and self.sort_mode in ("focus", "hot"):
663
+ summary.append(f"{len(quiet)} quiet folders · {quiet_unread} unread")
664
+ if summary:
665
+ focus_summary = " … " + " · ".join(summary) + " hidden"
666
+
667
+ ticker_lines = min(3, len(self.ticker))
668
+ tip_line = None if browsing else self._tip_line(now, width)
669
+ # Body overflow adds a visible "files hidden" row and focus mode may
670
+ # add its own summary. Reserve both up front: the live footer (including
671
+ # its tip and key legend) must never be pushed below the terminal edge.
672
+ overflow_rows = 1
673
+ summary_rows = 1 if focus_summary else 0
674
+ fixed = len(header) + 2 + ticker_lines + 3 + bool(tip_line) + overflow_rows + summary_rows
675
+ budget = max(4, height - fixed)
676
+ total = sum((1 if d else 0) + len(fs) for d, fs in folders.items())
677
+ hide_quiet = total > budget
678
+
679
+ body = []
680
+ stat_width = 8 if browsing else 16
681
+ name_column = max(18, width - stat_width - 8)
682
+ rendered_folders = sorted(
683
+ folders,
684
+ key=lambda d: self._folder_sort_key(d, now, browsing, folder_order.get(d, 0)),
685
+ )
686
+ if not browsing and self.sort_mode == "name" and self.name_desc:
687
+ rendered_folders = sorted(rendered_folders, reverse=True)
688
+ for folder in rendered_folders:
689
+ files = folders[folder]
690
+ if not browsing and self.sort_mode == "hot":
691
+ files = sorted(files, key=lambda f: (-heat_scores[f], f))
692
+ elif not browsing and self.sort_mode == "cold":
693
+ files = sorted(
694
+ files, key=lambda f: (bool(ALWAYS_LOADED.search(f)), heat_scores[f], f)
695
+ )
696
+ elif not browsing and self.sort_mode == "name":
697
+ files = sorted(files, reverse=self.name_desc)
698
+ if not browsing:
699
+ if self.sort_mode == "cold":
700
+ shown = [f for f in files if not ALWAYS_LOADED.search(f)]
701
+ elif self.sort_mode == "name":
702
+ shown = files
703
+ else:
704
+ shown = [f for f in files if counts[f] or self._glow(f, now) > 0]
705
+ elif hide_quiet:
706
+ shown = [f for f in files if counts[f] or self._glow(f, now) > 0]
707
+ else:
708
+ shown = files
709
+ if folder:
710
+ color, bold = self._node_color(
711
+ self._heat(folder_heat.get(folder, 0)), self._glow(folder, now)
712
+ )
713
+ searches = normalized_scans[folder]
714
+ unread = sum(1 for f in inventory_folders.get(folder, []) if not counts[f])
715
+ status = []
716
+ if searches:
717
+ status.append(f"🔍 {searches} search{'es' if searches != 1 else ''}")
718
+ if unread:
719
+ status.append(f"{unread} unread")
720
+ suffix = c256(DEAD, " · " + " · ".join(status)) if status else ""
721
+ body.append(c256(color, f" {folder}/", bold) + suffix)
722
+ for i, f in enumerate(shown):
723
+ count = counts[f]
724
+ heat = heat_scores[f]
725
+ injected = bool(ALWAYS_LOADED.search(f))
726
+ glow = self._glow(f, now)
727
+ color, bold = self._node_color(INJECTED if injected else self._heat(heat), glow)
728
+ branch = "└─" if i == len(shown) - 1 else "├─"
729
+ name = os.path.basename(f) if folder else f
730
+ prefix = f" {branch} " if folder else " "
731
+ max_name = max(1, name_column - len(prefix))
732
+ if len(name) > max_name:
733
+ name = name[: max_name - 1] + "…"
734
+ pad = " " * max(2, name_column - len(prefix) - len(name))
735
+ if injected:
736
+ stat_text = "injected" + (f" · {count}×" if count else "")
737
+ stat = c256(INJECTED, stat_text, bold=True)
738
+ elif count:
739
+ stat_text = (
740
+ f"{self._heat_bar(heat, max_heat)} {count:>3}"
741
+ if browsing
742
+ else f"{self._heat_bar(heat, max_heat)} h{heat:>4.1f} · {count}×"
743
+ )
744
+ stat = c256(color, stat_text, bold)
745
+ else:
746
+ stat = c256(DEAD, f"· {0:>3}")
747
+ body.append(
748
+ c256(244 if folder else DIM, prefix) + c256(color, name, bold) + pad + stat
749
+ )
750
+
751
+ body_budget = max(1, budget)
752
+ if len(body) > body_budget:
753
+ hidden_extra = len(body) - body_budget
754
+ body = body[:body_budget]
755
+ body.append(c256(DEAD, f" … {hidden_extra} files hidden"))
756
+ if focus_summary:
757
+ body.append(c256(DEAD, focus_summary))
758
+ if not browsing and not body and not self.total_reads and not self.total_scans:
759
+ body.extend(
760
+ [
761
+ c256(WHITE, " No discovery evidence yet.", bold=True),
762
+ c256(DIM, " Work normally — reads and explicit searches light up here."),
763
+ c256(COOL, " Want the loop now? Run /tt watch demo."),
764
+ ]
765
+ )
766
+
767
+ lines = header + body
768
+ lines.append("")
769
+ lines.append(
770
+ c256(DIM, " ")
771
+ + c256(WHITE, f"{self.total_prompts}", bold=True)
772
+ + c256(DIM, " prompts · ")
773
+ + c256(WHITE, f"{self.total_reads}", bold=True)
774
+ + c256(DIM, " reads · ")
775
+ + c256(WHITE, f"{self.total_scans}", bold=True)
776
+ + c256(DIM, " searches · ")
777
+ + c256(WHITE, f"{self.total_skills}", bold=True)
778
+ + c256(DIM, " skill uses · ")
779
+ + c256(WHITE, f"{len(self.sessions)}", bold=True)
780
+ + c256(DIM, " sessions")
781
+ )
782
+ lines.append(self._heat_legend(width))
783
+ if browsing:
784
+ icons = {"read": "●", "scan": "🔍", "skill": "⚡"}
785
+ for e in self.buckets[self.selected]["events"][-3:]:
786
+ who = "" if e["agent"] == "main" else f" [{e['agent']}]"
787
+ stamp = (e["ts"] or "")[11:19]
788
+ lines.append(c256(DIM, f" {icons[e['t']]} {e['path']}{who} · {stamp}"))
789
+ else:
790
+ for ts, icon, path, agent in list(self.ticker)[:3]:
791
+ age = now - ts
792
+ agestr = (
793
+ "just now"
794
+ if age < 3
795
+ else (f"{age:.0f}s ago" if age < 60 else f"{age/60:.0f}m ago")
796
+ )
797
+ who = "" if agent in ("main", "prompt") else f" [{agent}]"
798
+ fade = DIM if age < 8 else DEAD
799
+ lines.append(c256(fade, f" {icon} {path}{who} · {agestr}"))
800
+ if browsing:
801
+ lines.append(
802
+ c256(DEAD, " ← older prompt · → newer prompt · a live overview · q quit")
803
+ )
804
+ else:
805
+ if self.last_event is None:
806
+ beat = "listening for doc reads; searches and prompts appear as they arrive"
807
+ else:
808
+ age = now - self.last_event
809
+ beat = (
810
+ "last event just now"
811
+ if age < 3
812
+ else (
813
+ f"last event {age:.0f}s ago"
814
+ if age < 60
815
+ else f"last event {age/60:.0f}m ago"
816
+ )
817
+ )
818
+ lines.append(c256(AMBER, self._sort_legend(width), bold=True))
819
+ if tip_line:
820
+ lines.append(tip_line)
821
+ lines.append(c256(DEAD, f" ←/→ prompts · q quit · {beat}"))
822
+ return [ln[: width * 4] for ln in lines[:height]] # *4: ANSI codes don't count
823
+
824
+
825
+ def normalize_escape(seq):
826
+ """Map a terminal arrow-key escape tail to its bracket equivalent."""
827
+ # Terminals may send CSI (`[C`), application-cursor (`OC`) or modified
828
+ # variants (`[1;5C`). The final byte carries the direction in all of them.
829
+ if seq.startswith(("[", "O")) and seq.endswith("D"):
830
+ return "["
831
+ if seq.startswith(("[", "O")) and seq.endswith("C"):
832
+ return "]"
833
+ return None
834
+
835
+
836
+ def normalize_windows(code):
837
+ """Map a Windows console extended key code to its bracket equivalent."""
838
+ return {"K": "[", "M": "]"}.get(code)
839
+
840
+
841
+ def read_key(fd):
842
+ """Read one keypress from a raw fd, arrow escapes normalized to [ / ].
843
+
844
+ Must use os.read, never sys.stdin: the buffered reader slurps the whole
845
+ escape sequence off the fd in one read(1), the follow-up select() then sees
846
+ an empty fd, and the leftover "[" is replayed on the *next* keypress — so
847
+ every arrow key acted as "prev" one press late.
848
+ """
849
+ ch = os.read(fd, 1).decode(errors="replace")
850
+ if ch == "\x1b":
851
+ # Reads from a tty are allowed to return a partial escape sequence. Keep
852
+ # consuming briefly until its final byte instead of leaving `[` behind
853
+ # to be mistaken for "previous" on the next keypress.
854
+ tail = bytearray()
855
+ while len(tail) < 16 and select.select([fd], [], [], ESCAPE_BYTE_TIMEOUT)[0]:
856
+ part = os.read(fd, 1)
857
+ if not part: # EOF after a lone escape; do not spin on a readable pipe
858
+ return ""
859
+ tail.extend(part)
860
+ if tail and (65 <= tail[-1] <= 90 or tail[-1] == 126):
861
+ return normalize_escape(tail.decode(errors="replace")) or ""
862
+ ch = normalize_escape(tail.decode(errors="replace")) or ""
863
+ return ch
864
+
865
+
866
+ def handle_key(app, ch):
867
+ """Key dispatch for the interactive loop. Returns True when the watcher should quit."""
868
+ if app.settings_open:
869
+ if ch in ("s", "S", "\x1b"):
870
+ app.settings_open = False
871
+ elif ch in ("1", "2", "3"):
872
+ app.set_prompt_mode({"1": "truncate", "2": "hash", "3": "off"}[ch])
873
+ return False
874
+ if ch in ("q", "Q"):
875
+ return True
876
+ if ch == "[":
877
+ app.select_prev()
878
+ elif ch == "]":
879
+ app.select_next()
880
+ elif ch in ("a", "A"):
881
+ app.select_live()
882
+ elif ch in ("f", "F"):
883
+ app.set_sort("focus")
884
+ elif ch in ("h", "H"):
885
+ app.set_sort("hot")
886
+ elif ch in ("c", "C"):
887
+ app.set_sort("cold")
888
+ elif ch in ("n", "N"):
889
+ app.set_sort("name")
890
+ elif ch in ("s", "S"):
891
+ app.settings_open = True
892
+ app.settings_message = ""
893
+ return False
894
+
895
+
896
+ def demo_event(files, rng):
897
+ hot = rng.sample(files, min(6, len(files))) # a "task" keeps favoring a few files
898
+
899
+ def gen():
900
+ while True:
901
+ roll = rng.random()
902
+ if roll < 0.12:
903
+ yield {
904
+ "t": "scan",
905
+ "path": rng.choice(["docs", "docs/development", "agents"]),
906
+ "session": "demo",
907
+ "agent": "main",
908
+ }
909
+ elif roll < 0.2:
910
+ yield {
911
+ "t": "skill",
912
+ "skill": rng.choice(["doc-update", "insights", "tt"]),
913
+ "session": "demo",
914
+ "agent": "main",
915
+ }
916
+ else:
917
+ pool = hot if rng.random() < 0.7 else files
918
+ agent = rng.choice(["main", "main", "main", "Explore", "Plan"])
919
+ yield {"t": "read", "path": rng.choice(pool), "session": "demo", "agent": agent}
920
+
921
+ return gen()
922
+
923
+
924
+ def main():
925
+ ap = argparse.ArgumentParser(description=__doc__)
926
+ ap.add_argument("--demo", action="store_true", help="synthetic events (writes nothing)")
927
+ ap.add_argument("--replay", action="store_true", help="replay the real history, accelerated")
928
+ ap.add_argument("--seconds", type=float, default=0, help="exit automatically after N seconds")
929
+ ap.add_argument("--client", choices=("auto", "claude", "codex"), default="auto")
930
+ args = ap.parse_args()
931
+
932
+ app = App(inventory(), load_tips(detect_client(args.client)))
933
+ tail = Tail(HIST)
934
+ replay_events, replay_i = [], 0
935
+ if args.replay:
936
+ replay_events = load_all_events()
937
+ else:
938
+ for ev in iter_all_events():
939
+ app.feed(ev, live=False) # historic counts, no flashing
940
+ rng = random.Random()
941
+ demo = demo_event(app.files or ["CLAUDE.md"], rng) if args.demo else None
942
+ next_evt = time.time() + 0.5
943
+ next_inventory_sync = time.time() + INVENTORY_SYNC_SECS
944
+
945
+ is_tty = sys.stdout.isatty()
946
+ stdin_tty = sys.stdin.isatty()
947
+ use_termios = stdin_tty and os.name != "nt"
948
+ old_term = None
949
+ if is_tty:
950
+ # Full-screen TUI contract: private screen, no cursor, no line wrapping,
951
+ # and no stale scrollback. Autowrap is restored in finally even on Ctrl+C.
952
+ sys.stdout.write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J\x1b[3J\x1b[H")
953
+ if use_termios: # pragma: no cover — needs a real tty
954
+ import termios
955
+ import tty
956
+
957
+ old_term = termios.tcgetattr(sys.stdin)
958
+ tty.setcbreak(sys.stdin.fileno())
959
+
960
+ start = time.time()
961
+ try:
962
+ while True:
963
+ now = time.time()
964
+ if args.seconds and now - start >= args.seconds:
965
+ break
966
+ if os.name == "nt" and stdin_tty: # pragma: no cover — Windows console keys
967
+ import msvcrt
968
+
969
+ if msvcrt.kbhit():
970
+ wch = msvcrt.getwch()
971
+ if wch in ("\x00", "\xe0"):
972
+ wch = normalize_windows(msvcrt.getwch()) or ""
973
+ if wch and handle_key(app, wch):
974
+ break
975
+ elif use_termios and select.select([sys.stdin], [], [], 0)[0]: # pragma: no cover
976
+ ch = read_key(sys.stdin.fileno())
977
+ if ch and handle_key(app, ch):
978
+ break
979
+ if args.demo and now >= next_evt:
980
+ app.feed(next(demo))
981
+ next_evt = now + rng.uniform(0.3, 1.4)
982
+ elif args.replay and now >= next_evt and replay_i < len(replay_events):
983
+ app.feed(replay_events[replay_i])
984
+ replay_i += 1
985
+ next_evt = now + 0.35
986
+ elif not args.demo and not args.replay:
987
+ for ev in tail.poll():
988
+ app.feed(ev)
989
+ if now >= next_inventory_sync:
990
+ app.sync_inventory(inventory())
991
+ next_inventory_sync = now + INVENTORY_SYNC_SECS
992
+ size = shutil.get_terminal_size(fallback=(100, 34))
993
+ frame = app.render(now, size.columns, size.lines)
994
+ if is_tty:
995
+ sys.stdout.write("\x1b[H" + "\x1b[K\n".join(frame) + "\x1b[0J")
996
+ else:
997
+ sys.stdout.write("\n".join(frame) + "\n--frame--\n")
998
+ sys.stdout.flush()
999
+ time.sleep(1 / 12)
1000
+ except KeyboardInterrupt:
1001
+ pass
1002
+ finally:
1003
+ if use_termios and old_term is not None: # pragma: no cover — needs a real tty
1004
+ import termios
1005
+
1006
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_term)
1007
+ if is_tty:
1008
+ sys.stdout.write("\x1b[?7h\x1b[?25h\x1b[?1049l")
1009
+ sys.stdout.flush()
1010
+
1011
+
1012
+ if __name__ == "__main__":
1013
+ main()