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.
- trigger_tree/__init__.py +1 -0
- trigger_tree/_scripts/tt-codex-hook.py +120 -0
- trigger_tree/_scripts/tt-config.sh +32 -0
- trigger_tree/_scripts/tt-doctor.py +288 -0
- trigger_tree/_scripts/tt-log.py +656 -0
- trigger_tree/_scripts/tt-open.sh +164 -0
- trigger_tree/_scripts/tt-publish-badge.sh +26 -0
- trigger_tree/_scripts/tt-report.py +623 -0
- trigger_tree/_scripts/tt-setup.py +266 -0
- trigger_tree/_scripts/tt-shell-capture.sh +48 -0
- trigger_tree/_scripts/tt-stats.py +937 -0
- trigger_tree/_scripts/tt-statusline.py +154 -0
- trigger_tree/_scripts/tt-suggestions.py +128 -0
- trigger_tree/_scripts/tt-tips.py +107 -0
- trigger_tree/_scripts/tt-uninstall.py +60 -0
- trigger_tree/_scripts/tt-watch.py +1013 -0
- trigger_tree/_scripts/tt_runtime.py +27 -0
- trigger_tree/_scripts/tt_scope.py +76 -0
- trigger_tree/_scripts/validate_palette.js +28 -0
- trigger_tree/cli.py +52 -0
- trigger_tree/hooks/claude-hooks.json +11 -0
- trigger_tree/hooks/hooks.json +9 -0
- trigger_tree-1.14.0.dist-info/METADATA +106 -0
- trigger_tree-1.14.0.dist-info/RECORD +27 -0
- trigger_tree-1.14.0.dist-info/WHEEL +4 -0
- trigger_tree-1.14.0.dist-info/entry_points.txt +2 -0
- trigger_tree-1.14.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|