#!/usr/bin/env python3
"""Claude Code Subagent Viewer shows the running Claude subagent working — live
transcript + token count. Native window: title bar (X), drag, resize. No
terminal. Read-only viewer; cannot interfere with anything.
"""
import fcntl, glob, json, os, re, subprocess, sys, time, tkinter as tk
from tkinter import colorchooser
from tkinter.scrolledtext import ScrolledText

# ---------------------------------------------------------------------------
# Single-instance lock — keep this fd open for the entire process lifetime.
# ---------------------------------------------------------------------------
def _acquire_instance_lock():
    """Try to grab an exclusive flock on a well-known lockfile.

    Returns the open file object (keeping the lock alive) on success.
    On failure (another instance owns the lock), tries to raise the existing
    window via wmctrl, then exits.
    """
    runtime = os.environ.get("XDG_RUNTIME_DIR", "")
    if runtime and os.path.isdir(runtime):
        lock_path = os.path.join(runtime, "claude-code-subagent-viewer.lock")
    else:
        cache = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
        os.makedirs(cache, exist_ok=True)
        lock_path = os.path.join(cache, "claude-code-subagent-viewer.lock")
    try:
        fd = open(lock_path, "w")
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        return fd  # caller must keep this reference alive
    except OSError:
        # Another instance holds the lock — raise its window if possible.
        import shutil
        if shutil.which("wmctrl"):
            subprocess.call(
                ["wmctrl", "-x", "-a", "claudecodesubagentviewer.ClaudeCodeSubagentViewer"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        else:
            print("claude-code-subagent-viewer is already running.")
        sys.exit(0)

_INSTANCE_LOCK = _acquire_instance_lock()

ROOT = os.path.expanduser("~/.claude/projects")
CONFIG_DIR = os.path.join(
    os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
    "claude-code-subagent-viewer",
)
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
DEFAULT_BORDER_COLOR = "#14b8a6"
DEFAULT_BORDER_WIDTH = 6
MIN_BORDER_WIDTH = 2
MAX_BORDER_WIDTH = 20
ALERT_SOUNDS = ("rise", "bell", "chord")
DEFAULT_ALERT = {"enabled": True, "volume": 70, "sound": "bell"}
# Resolve audio asset paths: prefer XDG data dir (installed), fall back to repo-relative (dev)
_SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
_XDG_DATA_HOME = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
_ASSETS_INSTALLED = os.path.join(_XDG_DATA_HOME, "claude-code-subagent-viewer", "assets")
_ASSETS_REPO = os.path.join(_SCRIPT_DIR, "assets")
# paplay volume scale: 65536 = 100%
_PAPLAY_MAX = 65536

def alert_wav_path(sound):
    """Return the path for alert-<sound>.wav: installed XDG path preferred, repo fallback."""
    fname = "alert-%s.wav" % sound
    installed = os.path.join(_ASSETS_INSTALLED, fname)
    if os.path.exists(installed):
        return installed
    return os.path.join(_ASSETS_REPO, fname)

def play_alert(volume_pct, sound="bell"):
    """Play alert-<sound>.wav non-blocking. Tries pw-play, paplay, aplay in order."""
    wav = alert_wav_path(sound)
    if not os.path.exists(wav):
        return
    vol = max(0, min(100, int(volume_pct)))
    pa_vol = int(_PAPLAY_MAX * vol / 100)
    # Play to the CURRENT DEFAULT sink explicitly (@DEFAULT_SINK@). Without this,
    # PipeWire/PulseAudio module-stream-restore can route the player to whatever
    # sink it "remembers" (e.g. a Bluetooth speaker) instead of the default
    # output. An explicit --device overrides that memory. paplay is tried first
    # because it honors @DEFAULT_SINK@ portably.
    players = [
        ["paplay", "--device=@DEFAULT_SINK@", "--volume=%d" % pa_vol, wav],
        ["pw-play", "--volume=%.2f" % (vol / 100.0), wav],
        ["paplay", "--volume=%d" % pa_vol, wav],
        ["aplay", "-q", wav],
        ["canberra-gtk-play", "--file=%s" % wav],
    ]
    for cmd in players:
        try:
            subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                close_fds=True,
            )
            return
        except FileNotFoundError:
            continue

DEFAULT_TOKEN_COLORS = {
    "token_green": "#22c55e",
    "token_yellow": "#f0c24a",
    "token_orange": "#f97316",
    "token_red": "#ef4444",
}
DEFAULT_TOKEN_THRESHOLDS = {
    "green_under": 2000,
    "yellow_under": 10000,
    "orange_under": 20000,
}
TOKEN_COLOR_LABELS = {
    "token_green": "Low usage",
    "token_yellow": "Caution",
    "token_orange": "Warning",
    "token_red": "Critical",
}

def token_color(total, thresholds=None):
    thresholds = thresholds or DEFAULT_TOKEN_THRESHOLDS
    if total < thresholds["green_under"]:
        return "token_green"
    if total < thresholds["yellow_under"]:
        return "token_yellow"
    if total < thresholds["orange_under"]:
        return "token_orange"
    return "token_red"

def newest():
    fs = glob.glob(os.path.join(ROOT, "**", "subagents", "agent-*.jsonl"), recursive=True)
    return max(fs, key=os.path.getmtime) if fs else None

def base(p):
    return os.path.basename(p) if p else ""

# Map a project-root directory name to a friendly display name.
FRIENDLY_PROJECTS = {
    "claude-code-subagent-viewer": "Claude Code Subagent Viewer",
    "the-pig-farm": "The Pig Farm",
    "workflow-cockpit": "Workflow Cockpit",
    "naked-pulse": "Naked Pulse",
    "naked-pulse-tanstack": "Naked Pulse",
}
_HOME = os.path.expanduser("~")

def friendly_project(root_name):
    """Friendly name for a project root dir; title-case fallback for unknowns."""
    if not root_name:
        return None
    if root_name in FRIENDLY_PROJECTS:
        return FRIENDLY_PROJECTS[root_name]
    words = re.split(r"[-_]+", root_name)
    return " ".join(w[:1].upper() + w[1:] for w in words if w) or root_name

def project_from_path(path):
    """Infer a friendly project name from a file path.

    Recognizes a segment under ~/Projects/<name>/… or a top-level ~/<name>/…
    Returns the friendly name, or None if no project root is detectable.
    """
    if not path:
        return None
    p = path.strip().strip("'\"")
    if p.startswith("~"):
        p = _HOME + p[1:]
    p = os.path.normpath(p)
    if not p.startswith(_HOME + os.sep):
        return None
    rest = p[len(_HOME) + 1:]
    parts = rest.split(os.sep)
    if not parts or not parts[0]:
        return None
    if parts[0] == "Projects":
        if len(parts) >= 2 and parts[1]:
            return friendly_project(parts[1])
        return None
    # Top-level ~/<name>/… — filter out hidden dirs and standard non-project dirs.
    _HOME_DENYLIST = {
        "Desktop", "Downloads", "Documents", "Pictures", "Music", "Videos",
        "Public", "Templates", "bin", "snap", "Android", "go", "node_modules",
    }
    top = parts[0]
    if top.startswith(".") or top in _HOME_DENYLIST:
        return None
    # Only treat as a project when nested (has a child), so a bare file in $HOME
    # doesn't masquerade as a project.
    if len(parts) >= 2 and parts[1]:
        return friendly_project(top)
    return None

class Viewer:
    def __init__(self):
        self.win = tk.Tk(className="ClaudeCodeSubagentViewer")
        self.win.title("Claude Code Subagent Viewer")
        (
            self.token_colors,
            self.token_thresholds,
            self.border_color,
            self.border_width,
            self.alert,
        ) = self.load_settings()
        try:
            self._icon = tk.PhotoImage(file=os.path.expanduser("~/.local/share/icons/claude-code-subagent-viewer.png"))
            self.win.iconphoto(True, self._icon)
        except Exception:
            pass
        self.win.geometry("780x900")
        self.win.configure(bg=self.border_color)
        self.win.minsize(360, 240)

        # Custom top toolbar replaces the native Tk menubar (native menubars
        # can't hold widgets). LEFT = Options menubutton; CENTER = context box.
        self.toolbar = tk.Frame(self.win, bg="#0f1116", height=30)
        self.toolbar.pack(
            fill="x",
            side="top",
            padx=self.border_width,
            pady=(self.border_width, 0),
        )
        self.toolbar.pack_propagate(False)

        options_btn = tk.Menubutton(
            self.toolbar,
            text="Options",
            bg="#0f1116",
            fg="#d7dae0",
            activebackground="#22333a",
            activeforeground="#ffffff",
            relief="flat",
            bd=0,
            padx=10,
            pady=3,
            cursor="hand2",
        )
        options_menu = tk.Menu(options_btn, tearoff=False)
        options_menu.add_command(label="Tokens…", command=self.open_token_options)
        options_menu.add_command(label="Border…", command=self.open_border_options)
        options_menu.add_command(label="Alert…", command=self.open_alert_options)
        options_btn.configure(menu=options_menu)
        options_btn.pack(side="left", anchor="w")

        # CENTER: context box, centered in the toolbar's empty space.
        self.context_var = tk.StringVar(value="—")
        self.context_box = tk.Label(
            self.toolbar,
            textvariable=self.context_var,
            bg="#15171c",
            fg="#9ad7e0",
            font=("Monospace", 9),
            relief="solid",
            bd=1,
            padx=10,
            pady=2,
        )
        self.context_box.place(relx=0.5, rely=0.5, anchor="center")

        self.txt = ScrolledText(self.win, wrap="word", bg="#15171c", fg="#d7dae0",
                                insertbackground="#d7dae0", relief="flat", padx=12, pady=10,
                                font=("Monospace", 10), borderwidth=0)
        self.txt.pack(
            fill="both",
            expand=True,
            padx=self.border_width,
            pady=(self.border_width, 0),
        )
        for tag, col in (("text", "#e6e8ee"), ("bash", "#67e08a"), ("edit", "#f0c24a"),
                         ("read", "#5aa9ff"), ("find", "#5aa9ff"), ("tool", "#43c0d6"),
                         ("res", "#7a8190"), ("sep", "#b48cff"), ("todo", "#d98cff")):
            self.txt.tag_config(tag, foreground=col)
        self.txt.tag_config("sep", foreground="#b48cff", font=("Monospace", 10, "bold"))
        self.txt.tag_config("res", foreground="#7a8190", font=("Monospace", 9))
        self.txt.configure(state="disabled")

        self.status = tk.Text(self.win, height=1, wrap="none", bg="#0f1116",
                              fg="#9aa0ac", relief="flat", padx=12, pady=5,
                              font=("Monospace", 9), borderwidth=0,
                              highlightthickness=0, cursor="arrow")
        self.apply_token_colors()
        self.set_status("waiting for a subagent…")

        # Speaker toggle button (mute indicator) in the status bar
        self._speaker_btn = tk.Button(
            self.win,
            text=self._speaker_icon(),
            font=("Monospace", 10),
            bg="#0f1116",
            fg="#9aa0ac",
            activebackground="#0f1116",
            activeforeground="#d7dae0",
            relief="flat",
            bd=0,
            padx=4,
            pady=3,
            cursor="hand2",
            command=self.toggle_mute,
        )
        self._speaker_btn.pack(
            side="bottom",
            anchor="e",
            padx=(0, self.border_width),
            pady=self.border_width,
        )

        self.status.pack(
            fill="x",
            side="bottom",
            padx=self.border_width,
            pady=(self.border_width, 0),
        )

        # Working / done badge, anchored inside the upper-right corner.
        self.badge = tk.Label(self.txt, text="····", width=4, bg="#f0c24a", fg="#15171c",
                              font=("Monospace", 12, "bold"), padx=4, pady=1)
        self.badge.place(relx=1, x=-10, y=8, anchor="ne")

        # Ctrl + mouse-wheel zoom
        for w in (self.win, self.txt):
            w.bind("<Control-Button-4>", lambda e: self.zoom(1))
            w.bind("<Control-Button-5>", lambda e: self.zoom(-1))
            w.bind("<Control-MouseWheel>", lambda e: self.zoom(1 if e.delta > 0 else -1))

        self.path = None
        self.offset = 0
        self.out_tok = 0
        self.cache = 0
        self.model = ""
        self.fontsize = 10
        self.last_activity = time.time()
        self.dotn = 0
        # Context indicator state: shown project (debounced) + live activity.
        self.shown_project = None
        self.activity = ""
        # Rolling window of the most recently seen projects, for debounce: a
        # different project must appear in >=2 recent tool calls before it
        # replaces the shown one. A one-off unrelated path won't flip it.
        self._recent_projects = []
        # Badge state: track whether we were "working" on the previous poll so
        # we can fire the alert exactly once on the working→done transition.
        self._badge_was_working = False
        # Suppress alert on startup (don't fire if already idle at launch).
        self._startup_idle_seen = False
        self.win.after(300, self.poll)

    def load_settings(self):
        # One-time migration: carry saved settings from the most recent old slug.
        # Check claude-subagent-viewer first (direct predecessor), then the
        # original sub-agent-sonnet-viewer slug.
        import shutil as _shutil
        _config_base = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
        _predecessors = [
            os.path.join(_config_base, "claude-subagent-viewer", "config.json"),
            os.path.join(_config_base, "sub-agent-sonnet-viewer", "config.json"),
        ]
        if not os.path.exists(CONFIG_FILE):
            for _old_config in _predecessors:
                if os.path.exists(_old_config):
                    try:
                        os.makedirs(CONFIG_DIR, exist_ok=True)
                        _shutil.copy2(_old_config, CONFIG_FILE)
                    except Exception:
                        pass
                    break
        try:
            with open(CONFIG_FILE, "r", encoding="utf-8") as config:
                saved = json.load(config)
        except (OSError, ValueError, TypeError):
            saved = {}
        if not isinstance(saved, dict):
            saved = {}
        saved_colors = saved.get("token_colors", {})
        saved_thresholds = saved.get("token_thresholds", {})
        colors = {
            tag: saved_colors.get(tag, default)
            for tag, default in DEFAULT_TOKEN_COLORS.items()
        }
        thresholds = {
            name: saved_thresholds.get(name, default)
            for name, default in DEFAULT_TOKEN_THRESHOLDS.items()
        }
        if not self.valid_thresholds(thresholds):
            thresholds = DEFAULT_TOKEN_THRESHOLDS.copy()
        border_color = saved.get("border_color", DEFAULT_BORDER_COLOR)
        if not isinstance(border_color, str):
            border_color = DEFAULT_BORDER_COLOR
        border_width = saved.get("border_width", DEFAULT_BORDER_WIDTH)
        if not isinstance(border_width, int) or not MIN_BORDER_WIDTH <= border_width <= MAX_BORDER_WIDTH:
            border_width = DEFAULT_BORDER_WIDTH
        saved_alert = saved.get("alert", {})
        if not isinstance(saved_alert, dict):
            saved_alert = {}
        saved_sound = saved_alert.get("sound", DEFAULT_ALERT["sound"])
        if saved_sound not in ALERT_SOUNDS:
            saved_sound = DEFAULT_ALERT["sound"]
        alert = {
            "enabled": bool(saved_alert.get("enabled", DEFAULT_ALERT["enabled"])),
            "volume": max(0, min(100, int(saved_alert.get("volume", DEFAULT_ALERT["volume"])))),
            "sound": saved_sound,
        }
        return colors, thresholds, border_color, border_width, alert

    def save_settings(self):
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(CONFIG_FILE, "w", encoding="utf-8") as config:
            json.dump({
                "token_colors": self.token_colors,
                "token_thresholds": self.token_thresholds,
                "border_color": self.border_color,
                "border_width": self.border_width,
                "alert": self.alert,
            }, config, indent=2)
            config.write("\n")

    @staticmethod
    def valid_thresholds(thresholds):
        try:
            green = int(thresholds["green_under"])
            yellow = int(thresholds["yellow_under"])
            orange = int(thresholds["orange_under"])
        except (KeyError, TypeError, ValueError):
            return False
        return 0 < green < yellow < orange

    def apply_token_colors(self):
        for tag, color in self.token_colors.items():
            self.status.tag_config(tag, foreground=color)

    def apply_border(self):
        self.win.configure(bg=self.border_color)
        self.toolbar.pack_configure(
            padx=self.border_width,
            pady=(self.border_width, 0),
        )
        self.txt.pack_configure(
            padx=self.border_width,
            pady=(self.border_width, 0),
        )
        self.status.pack_configure(
            padx=self.border_width,
            pady=(self.border_width, 0),
        )
        self._speaker_btn.pack_configure(
            padx=(0, self.border_width),
            pady=self.border_width,
        )

    def _flash_saved(self):
        """Show 'Saved' in the main status bar for 2 seconds, then restore."""
        self.status.configure(state="normal")
        self.status.delete("1.0", "end")
        self.status.insert("end", "  Saved")
        self.status.configure(state="disabled")
        self.win.after(2000, self.refresh_status)

    def _close_dialog(self, dialog):
        dialog.destroy()
        self._flash_saved()

    def open_border_options(self):
        dialog = tk.Toplevel(self.win)
        dialog.title("Border Settings")
        dialog.configure(bg="#15171c")
        dialog.resizable(False, False)
        dialog.transient(self.win)
        dialog.grab_set()

        width = tk.IntVar(value=self.border_width)
        tk.Label(
            dialog,
            text="Width",
            bg="#15171c",
            fg="#e6e8ee",
            anchor="w",
        ).grid(row=0, column=0, padx=(16, 8), pady=(14, 6), sticky="w")
        width_input = tk.Spinbox(
            dialog,
            from_=MIN_BORDER_WIDTH,
            to=MAX_BORDER_WIDTH,
            textvariable=width,
            width=8,
            justify="right",
        )
        width_input.grid(row=0, column=1, padx=(8, 16), pady=(14, 6))
        tk.Label(
            dialog,
            text="Color",
            bg="#15171c",
            fg="#e6e8ee",
            anchor="w",
        ).grid(row=1, column=0, padx=(16, 8), pady=6, sticky="w")
        color_button = tk.Button(
            dialog,
            text="Border",
            width=10,
            bg=self.border_color,
            activebackground=self.border_color,
        )
        color_button.grid(row=1, column=1, padx=(8, 16), pady=6)
        save_status = tk.Label(
            dialog,
            text="",
            bg="#15171c",
            fg="#ef4444",
            anchor="w",
        )
        save_status.grid(row=2, column=0, columnspan=2, padx=16, pady=(4, 0), sticky="w")

        def save_width(*_):
            try:
                value = width.get()
            except tk.TclError:
                save_status.configure(text="Enter a whole number.")
                return
            if not MIN_BORDER_WIDTH <= value <= MAX_BORDER_WIDTH:
                save_status.configure(
                    text="Width must be %d-%d pixels." % (MIN_BORDER_WIDTH, MAX_BORDER_WIDTH),
                )
                return
            save_status.configure(text="")
            self.border_width = value
            self.apply_border()
            self.save_settings()

        def pick_color():
            _, color = colorchooser.askcolor(
                color=self.border_color,
                title="Border color",
                parent=dialog,
            )
            if color:
                self.border_color = color
                color_button.configure(bg=color, activebackground=color)
                self.apply_border()
                self.save_settings()

        def reset():
            self.border_color = DEFAULT_BORDER_COLOR
            self.border_width = DEFAULT_BORDER_WIDTH
            width.set(DEFAULT_BORDER_WIDTH)
            color_button.configure(
                bg=DEFAULT_BORDER_COLOR,
                activebackground=DEFAULT_BORDER_COLOR,
            )
            self.apply_border()
            self.save_settings()
            save_status.configure(text="")

        color_button.configure(command=pick_color)
        width.trace_add("write", save_width)
        actions = tk.Frame(dialog, bg="#15171c")
        actions.grid(row=3, column=0, columnspan=2, padx=16, pady=14, sticky="e")
        tk.Button(actions, text="Reset defaults", command=reset).pack(side="left", padx=4)
        tk.Button(actions, text="Close", command=lambda: self._close_dialog(dialog)).pack(side="left", padx=4)

    def open_alert_options(self):
        dialog = tk.Toplevel(self.win)
        dialog.title("Alert Settings")
        dialog.configure(bg="#15171c")
        dialog.resizable(False, False)
        dialog.transient(self.win)
        dialog.grab_set()

        # Mute toggle
        mute_var = tk.BooleanVar(value=self.alert["enabled"])
        tk.Label(
            dialog, text="Alert enabled", bg="#15171c", fg="#e6e8ee", anchor="w",
        ).grid(row=0, column=0, padx=(16, 8), pady=(14, 6), sticky="w")
        mute_chk = tk.Checkbutton(
            dialog,
            variable=mute_var,
            bg="#15171c",
            activebackground="#15171c",
            selectcolor="#22333a",
        )
        mute_chk.grid(row=0, column=1, padx=(8, 16), pady=(14, 6), sticky="w")

        # Volume
        vol_var = tk.IntVar(value=self.alert["volume"])
        tk.Label(
            dialog, text="Volume (0–100%)", bg="#15171c", fg="#e6e8ee", anchor="w",
        ).grid(row=1, column=0, padx=(16, 8), pady=6, sticky="w")
        vol_frame = tk.Frame(dialog, bg="#15171c")
        vol_frame.grid(row=1, column=1, padx=(8, 16), pady=6, sticky="w")
        vol_scale = tk.Scale(
            vol_frame,
            from_=0,
            to=100,
            orient="horizontal",
            variable=vol_var,
            length=140,
            bg="#15171c",
            fg="#e6e8ee",
            troughcolor="#22333a",
            highlightthickness=0,
            showvalue=True,
        )
        vol_scale.pack(side="left")

        # Sound selector
        sound_var = tk.StringVar(value=self.alert["sound"])
        tk.Label(
            dialog, text="Sound", bg="#15171c", fg="#e6e8ee", anchor="w",
        ).grid(row=2, column=0, padx=(16, 8), pady=6, sticky="w")
        sound_frame = tk.Frame(dialog, bg="#15171c")
        sound_frame.grid(row=2, column=1, padx=(8, 16), pady=6, sticky="w")
        for label, value in (("Rise", "rise"), ("Bell", "bell"), ("Chord", "chord")):
            tk.Radiobutton(
                sound_frame,
                text=label,
                variable=sound_var,
                value=value,
                bg="#15171c",
                fg="#e6e8ee",
                activebackground="#15171c",
                activeforeground="#e6e8ee",
                selectcolor="#22333a",
            ).pack(side="left", padx=(0, 8))

        save_status = tk.Label(
            dialog, text="", bg="#15171c", fg="#f0c24a", anchor="w",
        )
        save_status.grid(row=3, column=0, columnspan=2, padx=16, pady=(4, 0), sticky="w")

        def autosave(*_):
            self.alert["enabled"] = bool(mute_var.get())
            self.alert["volume"] = max(0, min(100, int(vol_var.get())))
            s = sound_var.get()
            if s in ALERT_SOUNDS:
                self.alert["sound"] = s
            self._speaker_btn.configure(text=self._speaker_icon())
            self.save_settings()

        mute_var.trace_add("write", autosave)
        vol_var.trace_add("write", autosave)
        sound_var.trace_add("write", autosave)

        def test_sound():
            save_status.configure(text="")
            play_alert(self.alert["volume"], self.alert["sound"])

        def reset():
            mute_var.set(DEFAULT_ALERT["enabled"])
            vol_var.set(DEFAULT_ALERT["volume"])
            sound_var.set(DEFAULT_ALERT["sound"])
            save_status.configure(text="")

        actions = tk.Frame(dialog, bg="#15171c")
        actions.grid(row=4, column=0, columnspan=2, padx=16, pady=14, sticky="e")
        tk.Button(actions, text="Test sound", command=test_sound).pack(side="left", padx=4)
        tk.Button(actions, text="Reset defaults", command=reset).pack(side="left", padx=4)
        tk.Button(actions, text="Close", command=lambda: self._close_dialog(dialog)).pack(side="left", padx=4)

    def open_token_options(self):
        dialog = tk.Toplevel(self.win)
        dialog.title("Token Settings")
        dialog.configure(bg="#15171c")
        dialog.resizable(False, False)
        dialog.transient(self.win)
        dialog.grab_set()

        colors = self.token_colors.copy()
        starts = {
            "token_yellow": tk.IntVar(value=self.token_thresholds["green_under"]),
            "token_orange": tk.IntVar(value=self.token_thresholds["yellow_under"]),
            "token_red": tk.IntVar(value=self.token_thresholds["orange_under"]),
        }
        color_buttons = {}

        tk.Label(
            dialog,
            text="Level",
            bg="#15171c",
            fg="#7a8190",
            anchor="w",
        ).grid(row=0, column=0, padx=(16, 8), pady=(14, 4), sticky="w")
        tk.Label(
            dialog,
            text="Starts at",
            bg="#15171c",
            fg="#7a8190",
            anchor="e",
        ).grid(row=0, column=1, padx=8, pady=(14, 4), sticky="e")
        tk.Label(
            dialog,
            text="Color",
            bg="#15171c",
            fg="#7a8190",
        ).grid(row=0, column=2, padx=(8, 16), pady=(14, 4))

        def pick_color(tag):
            _, color = colorchooser.askcolor(
                color=colors[tag],
                title="%s color" % TOKEN_COLOR_LABELS[tag],
                parent=dialog,
            )
            if color:
                colors[tag] = color
                color_buttons[tag].configure(bg=color, activebackground=color)
                self.token_colors = colors.copy()
                self.apply_token_colors()
                self.save_settings()
                self.refresh_status()

        rows = (
            ("token_green", "0 (fixed)"),
            ("token_yellow", starts["token_yellow"]),
            ("token_orange", starts["token_orange"]),
            ("token_red", starts["token_red"]),
        )
        for row, (tag, quantity) in enumerate(rows, start=1):
            tk.Label(
                dialog,
                text=TOKEN_COLOR_LABELS[tag],
                bg="#15171c",
                fg="#e6e8ee",
                width=12,
                anchor="w",
            ).grid(row=row, column=0, padx=(16, 8), pady=5, sticky="w")
            if isinstance(quantity, tk.IntVar):
                tk.Spinbox(
                    dialog,
                    from_=1,
                    to=10000000,
                    textvariable=quantity,
                    width=12,
                    justify="right",
                ).grid(row=row, column=1, padx=8, pady=5)
            else:
                tk.Label(
                    dialog,
                    text=quantity,
                    bg="#15171c",
                    fg="#9aa0ac",
                    width=12,
                    anchor="e",
                ).grid(row=row, column=1, padx=8, pady=5)
            button = tk.Button(
                dialog,
                text=TOKEN_COLOR_LABELS[tag],
                width=10,
                bg=colors[tag],
                activebackground=colors[tag],
                command=lambda color_tag=tag: pick_color(color_tag),
            )
            button.grid(row=row, column=2, padx=(8, 16), pady=5)
            color_buttons[tag] = button

        save_status = tk.Label(
            dialog,
            text="",
            bg="#15171c",
            fg="#ef4444",
            anchor="w",
        )
        save_status.grid(row=5, column=0, columnspan=3, padx=16, pady=(4, 0), sticky="w")

        def autosave_quantities(*_):
            try:
                thresholds = {
                    "green_under": starts["token_yellow"].get(),
                    "yellow_under": starts["token_orange"].get(),
                    "orange_under": starts["token_red"].get(),
                }
            except tk.TclError:
                save_status.configure(text="Enter whole numbers.")
                return
            if not self.valid_thresholds(thresholds):
                save_status.configure(
                    text="Caution, Warning, and Critical must increase in order.",
                )
                return
            save_status.configure(text="")
            self.token_thresholds = thresholds
            self.save_settings()
            self.refresh_status()

        for variable in starts.values():
            variable.trace_add("write", autosave_quantities)

        def reset():
            colors.update(DEFAULT_TOKEN_COLORS)
            starts["token_yellow"].set(DEFAULT_TOKEN_THRESHOLDS["green_under"])
            starts["token_orange"].set(DEFAULT_TOKEN_THRESHOLDS["yellow_under"])
            starts["token_red"].set(DEFAULT_TOKEN_THRESHOLDS["orange_under"])
            for tag, button in color_buttons.items():
                button.configure(
                    bg=colors[tag],
                    activebackground=colors[tag],
                )
            self.token_colors = colors.copy()
            self.token_thresholds = DEFAULT_TOKEN_THRESHOLDS.copy()
            self.apply_token_colors()
            self.save_settings()
            self.refresh_status()
            save_status.configure(text="")

        actions = tk.Frame(dialog, bg="#15171c")
        actions.grid(row=6, column=0, columnspan=3, padx=16, pady=14, sticky="e")
        tk.Button(actions, text="Reset defaults", command=reset).pack(side="left", padx=4)
        tk.Button(actions, text="Close", command=lambda: self._close_dialog(dialog)).pack(side="left", padx=4)

    def zoom(self, d):
        self.fontsize = max(6, min(34, self.fontsize + d))
        self.txt.configure(font=("Monospace", self.fontsize))
        self.txt.tag_config("res", font=("Monospace", max(6, self.fontsize - 1)))
        self.txt.tag_config("sep", font=("Monospace", self.fontsize, "bold"))
        return "break"

    def _speaker_icon(self):
        return "🔇" if not self.alert["enabled"] else "🔊"

    def toggle_mute(self):
        self.alert["enabled"] = not self.alert["enabled"]
        self._speaker_btn.configure(text=self._speaker_icon())
        self.save_settings()

    def update_badge(self):
        idle = time.time() - self.last_activity
        now_working = idle < 18
        if now_working:
            self.dotn = self.dotn % 4 + 1
            self.badge.config(text="." * self.dotn, bg="#f0c24a", fg="#15171c")
            self._badge_was_working = True
        else:
            self.badge.config(text="✓", bg="#22c55e", fg="#0f1116")
            # Fire the alert ONLY on a real working → done transition.
            # _badge_was_working starts False, so launch-while-idle never dings;
            # it only becomes True after a genuine working period.
            if self._badge_was_working:
                if self.alert["enabled"]:
                    play_alert(self.alert["volume"], self.alert["sound"])
                self._badge_was_working = False

    def _activity_phrase(self, name, inp):
        """Short live phrase describing the current tool call."""
        inp = inp or {}
        if name in ("Edit", "Write", "NotebookEdit"):
            return "✏️ editing %s" % base(inp.get("file_path", ""))
        if name == "Read":
            return "📖 reading %s" % base(inp.get("file_path", ""))
        if name == "Bash":
            return "$ %s" % inp.get("command", "")[:40]
        if name in ("Grep", "Glob"):
            return '🔎 "%s"' % inp.get("pattern", "")
        return ""

    def _observe_project(self, name, inp):
        """Return a friendly project for this tool call, if one is detectable.

        Derived ONLY from genuine file operations — the file_path input of
        Edit/Write/Read/NotebookEdit. Bash commands, grep patterns, edit
        contents, and assistant prose are deliberately ignored: scraping
        path-shaped strings out of free text produced false project flips
        (e.g. a transcript that merely *mentions* another project's path).
        """
        inp = inp or {}
        if name not in ("Edit", "Write", "Read", "NotebookEdit"):
            return None
        return project_from_path(inp.get("file_path"))

    def update_context(self, name, inp):
        """Update the centered context box from the latest tool call.

        Activity updates every call; project is debounced so a one-off path to
        a different project does not flip the shown project — a new project must
        appear in >=2 recent tool calls to take over.
        """
        phrase = self._activity_phrase(name, inp)
        if phrase:
            self.activity = phrase
        proj = self._observe_project(name, inp)
        if proj:
            self._recent_projects.append(proj)
            self._recent_projects = self._recent_projects[-4:]
            if self.shown_project is None:
                self.shown_project = proj
            elif proj != self.shown_project:
                # Flip only after the new project shows up in >=2 recent calls.
                if self._recent_projects.count(proj) >= 2:
                    self.shown_project = proj
        self.refresh_context_box()

    def refresh_context_box(self):
        proj = self.shown_project or "—"
        act = self.activity or "…"
        self.context_var.set("%s  ·  %s" % (proj, act))

    def add(self, s, tag="text"):
        at_bottom = self.txt.yview()[1] >= 0.999
        self.txt.configure(state="normal")
        self.txt.insert("end", s + "\n", tag)
        self.txt.configure(state="disabled")
        if at_bottom:
            self.txt.see("end")

    def set_status(self, text, token_text=None):
        self.status.configure(state="normal")
        self.status.delete("1.0", "end")
        self.status.insert("end", text)
        if token_text is not None:
            self.status.insert("end", token_text, token_color(self.out_tok, self.token_thresholds))
        self.status.configure(state="disabled")

    def refresh_status(self):
        if not self.path:
            self.set_status("waiting for a subagent…")
            return
        tok = "%.1fk" % (self.out_tok / 1000) if self.out_tok >= 1000 else str(self.out_tok)
        ctx = "%.0fk ctx" % (self.cache / 1000) if self.cache else ""
        self.set_status(
            "  %s · %s · 🪙 " % (base(self.path), self.model or "…"),
            "%s out" % tok,
        )
        self.status.configure(state="normal")
        self.status.insert("end", " · %s" % ctx)
        self.status.configure(state="disabled")

    def render(self, line):
        line = line.strip()
        if not line:
            return
        try:
            o = json.loads(line)
        except Exception:
            return
        typ = o.get("type")
        msg = o.get("message") or {}
        u = msg.get("usage") or {}
        if u:
            self.out_tok += u.get("output_tokens", 0) or 0
            self.cache = u.get("cache_read_input_tokens", 0) or self.cache
        if msg.get("model"):
            self.model = msg["model"]
        content = msg.get("content")
        if typ == "assistant":
            if isinstance(content, list):
                for b in content:
                    bt = b.get("type")
                    if bt == "text" and b.get("text", "").strip():
                        self.add(b["text"].strip(), "text")
                    elif bt == "tool_use":
                        self.add(*self.tool(b.get("name"), b.get("input")))
                        self.update_context(b.get("name"), b.get("input"))
            elif isinstance(content, str) and content.strip():
                self.add(content.strip(), "text")
        elif typ == "user" and isinstance(content, list):
            for b in content:
                if b.get("type") == "tool_result":
                    rc = b.get("content")
                    if isinstance(rc, list):
                        rc = " ".join(x.get("text", "") for x in rc if isinstance(x, dict))
                    rc = (rc or "").strip().split("\n")[0][:120]
                    if rc:
                        self.add("   ↳ " + rc, "res")

    def tool(self, name, inp):
        inp = inp or {}
        if name == "Bash":
            return "$ " + inp.get("command", "")[:200], "bash"
        if name in ("Edit", "Write", "NotebookEdit"):
            return "✏️  %s %s" % (name.lower(), base(inp.get("file_path", ""))), "edit"
        if name == "Read":
            return "📖 read " + base(inp.get("file_path", "")), "read"
        if name in ("Grep", "Glob"):
            return "🔎 %s %s" % (name.lower(), inp.get("pattern", "")), "find"
        if name == "TodoWrite":
            return "☑️  todo update", "todo"
        return "🔧 %s %s" % (name, str(inp)[:100]), "tool"

    def load(self, path):
        self.path = path
        self.offset = 0
        self.out_tok = 0
        self.cache = 0
        self.last_activity = time.time()
        # New transcript — reset the context indicator.
        self.shown_project = None
        self.activity = ""
        self._recent_projects = []
        self.refresh_context_box()
        self.txt.configure(state="normal")
        self.txt.delete("1.0", "end")
        self.txt.configure(state="disabled")
        self.add("── watching %s ──" % base(path), "sep")
        with open(path, "r", errors="replace") as f:
            lines = f.readlines()
            self.offset = f.tell()
        for ln in lines[-40:]:
            self.render(ln)
        # accumulate tokens from the lines we skipped too
        for ln in lines[:-40]:
            try:
                u = (json.loads(ln).get("message") or {}).get("usage") or {}
                self.out_tok += u.get("output_tokens", 0) or 0
            except Exception:
                pass

    def poll(self):
        try:
            nt = newest()
            if nt and nt != self.path:
                if self.path is None or os.path.getmtime(nt) > os.path.getmtime(self.path) + 1:
                    self.load(nt)
            if self.path and os.path.exists(self.path):
                prev = self.offset
                with open(self.path, "r", errors="replace") as f:
                    f.seek(self.offset)
                    for ln in f:
                        self.render(ln)
                    self.offset = f.tell()
                if self.offset > prev:
                    self.last_activity = time.time()
                self.refresh_status()
            self.update_badge()
        except Exception as e:
            self.set_status("  (viewer error: %s)" % e)
        self.win.after(800, self.poll)

if __name__ == "__main__":
    Viewer().win.mainloop()
