#!/usr/bin/env python3
"""SPLICE — AI-native terminal for Linux (GTK3 + VTE).

Real terminal first; AI layer (ask / chat / context selector) wired through the
sibling CLIs `splice-ai` and `splice-ctx`. All subprocess work runs in daemon
threads; UI mutation happens only via GLib.idle_add.

v1.3.0 surfaces (all local-first, zero tokens unless the user explicitly asks):
  F1/F2  rescue strip — per-tab FIFO ($SPLICE_EXITPIPE) carries exit codes from
         the shell; nonzero exits run `splice-ai rescue` (local rules+doctors)
         and dock a strip above the terminal. Tab inserts the fix (no newline,
         never executes), Space expands the doctor detail, Shift+A asks the AI
         with the failure attached, Esc/any-key/20s hides.
  F4     sentry — every ask-bar CMD result is classified via `splice-ai
         classify` before display; read=green chip, mutate=amber+effect,
         destructive=red + INSERT (GUARDED), block=insert disabled.
  F5     ask-attach — Ctrl+Shift+A attaches last command+output+exit to the ask
         bar (visible chip, removable); right-click "Ask about selection".
  F6     explain — Ctrl+Shift+E explains the selection if one exists, else the
         current prompt row (heuristic prompt-strip; the selection is the
         precise path — documented). Local man-parse card; [Shift+A] AI tier.
  F7     durable tabs — per-tab cwd/title/scrollback tail persisted (debounced
         5s after exit events) under ~/.cache/splice/tabs/<uuid>/; restore bar
         on next launch unless the last exit was a clean single-default-tab.
  F8     editor lifeline — pre-exec FIFO 'EDITOR' events arm a /proc poll; a
         GtkOverlay strip shows exit hints for vim/nano. Zero pty injection.
  F9     token ledger — per-ask estimates accumulated per day in
         ~/.config/splice/ledger.json; header chip + popover; >4k tok sends
         need a second Enter; optional daily budget warns, never blocks.

`python3 splice test` runs the headless logic self-test (no window needed).
"""
import base64
import gzip
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import uuid

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("Vte", "2.91")
from gi.repository import Gdk, Gio, GLib, Gtk, Pango, Vte  # noqa: E402

BIN_DIR = os.path.dirname(os.path.abspath(sys.argv[0]))


def _bootstrap_share():
    env = os.environ.get("SPLICE_SHARE")
    if env and os.path.isdir(env):
        return os.path.abspath(env)
    dev = os.path.normpath(os.path.join(BIN_DIR, "..", "share", "splice"))
    if os.path.isfile(os.path.join(dev, "splice_common.py")):
        return dev
    return "/usr/share/splice"


SHARE = _bootstrap_share()
sys.path.insert(0, SHARE)
import splice_common as sc  # noqa: E402

# ---------------------------------------------------------------- identity --
BG = "#101214"
PANEL = "#16191d"
FG = "#e6e3dc"
DIM = "#8a8f98"
ACCENT = "#ff6a00"
OK = "#3fb950"
ERR = "#f85149"

PALETTE16 = [
    "#16191d",  # 0 black
    "#f85149",  # 1 red
    "#3fb950",  # 2 green
    "#d29922",  # 3 yellow
    "#58a6ff",  # 4 blue
    "#bc8cff",  # 5 magenta
    "#39c5cf",  # 6 cyan
    "#c8ccd4",  # 7 white
    "#8a8f98",  # 8 bright black
    "#ff7b72",  # 9 bright red
    "#56d364",  # 10 bright green
    "#ff6a00",  # 11 bright yellow -> safety orange
    "#79c0ff",  # 12 bright blue
    "#d2a8ff",  # 13 bright magenta
    "#56d4dd",  # 14 bright cyan
    "#e6e3dc",  # 15 bright white
]

SCROLLBACK = 10000
RESTORE_TAIL_LINES = 2000
TAB_RETENTION_DAYS = 7
RESCUE_HIDE_S = 20
RESCUE_SKIP_CODES = {130, 146, 147, 148}  # SIGINT / job control — not failures
BIG_SEND_TOKENS_DEFAULT = 4000

# exit codes that arm the F8 poll are decided shell-side; these comm names
# decide which hint strip to show once the process is actually foreground.
VIM_COMMS = {"vim", "vi", "nvim", "view", "vimdiff", "vim.basic", "vim.tiny"}
NANO_COMMS = {"nano", "pico"}
GIT_CRON_RE = re.compile(r"^(?:sudo\s+)?(?:git\s+commit|crontab\s+-e)")


def rgba(hexstr):
    c = Gdk.RGBA()
    c.parse(hexstr)
    return c


def fmt_tokens(n):
    try:
        n = int(n)
    except (TypeError, ValueError):
        n = 0
    if n >= 1000:
        return "~%.1fk" % (n / 1000.0)
    return "~%d" % n


def cli_cmd(name, *args):
    """Command vector for a sibling CLI (dev mode: same dir as this script)."""
    path = os.path.join(BIN_DIR, name)
    if os.path.isfile(path):
        if os.access(path, os.X_OK):
            return [path] + list(args)
        return [sys.executable or "python3", path] + list(args)
    return [name] + list(args)  # installed on PATH


def run_cli(argv, timeout=120, input_text=None):
    """Blocking subprocess call — only ever invoked from worker threads."""
    env = os.environ.copy()
    env["SPLICE_SHARE"] = SHARE
    try:
        r = subprocess.run(
            argv, capture_output=True, text=True, timeout=timeout, env=env,
            input=input_text,
        )
        return r.returncode, (r.stdout or ""), (r.stderr or "")
    except subprocess.TimeoutExpired:
        return 1, "", "timed out after %ss" % timeout
    except OSError as e:
        return 1, "", str(e)


# ---------------------------------------------------------------------------
# headless logic helpers (no GTK use — exercised by `python3 splice test`)
# ---------------------------------------------------------------------------
def run_dir():
    d = os.path.join(sc.cache_dir(), "run")
    os.makedirs(d, exist_ok=True)
    return d


def tabs_root():
    d = os.path.join(sc.cache_dir(), "tabs")
    os.makedirs(d, exist_ok=True)
    return d


def session_file():
    return os.path.join(tabs_root(), "session.json")


def parse_fifo_line(line):
    """One FIFO record from splice.bashrc: '<exit>\\t<b64cmd>' or
    'EDITOR\\t<b64cmd>'. Returns ('exit', code, cmd) | ('editor', 0, cmd) |
    None for junk. Never raises."""
    if not line:
        return None
    parts = line.split("\t")
    if len(parts) != 2:
        return None
    tag, b64 = parts[0].strip(), parts[1].strip()
    try:
        cmd = base64.b64decode(b64.encode(), validate=False).decode(
            "utf-8", "replace"
        ) if b64 else ""
    except (ValueError, TypeError):
        cmd = ""
    if tag == "EDITOR":
        return ("editor", 0, cmd)
    try:
        return ("exit", int(tag), cmd)
    except ValueError:
        return None


def strip_prompt_prefix(line):
    """Heuristic: drop a leading shell prompt from a terminal row. Splits on
    the earliest '$ ' or '# ' marker near the line start (prompts live there;
    default PS1 ends in one). Commands whose own text contains an early
    marker mis-strip — the precise F6 path is a selection, documented."""
    line = (line or "").rstrip()
    best = -1
    for marker in ("$ ", "# "):
        i = line.find(marker)
        if i >= 0 and i < 80 and (best < 0 or i < best):
            best = i
    if best >= 0:
        return line[best + 2:].strip()
    return line.strip()


def tail_lines(text, n):
    lines = (text or "").splitlines()
    if len(lines) <= n:
        return "\n".join(lines)
    return "\n".join(lines[-n:])


def editor_kind(comm):
    comm = (comm or "").strip()
    if comm in NANO_COMMS:
        return "nano"
    if comm in VIM_COMMS:
        return "vim"
    return None


def proc_children(pid):
    try:
        with open("/proc/%d/task/%d/children" % (pid, pid)) as f:
            return [int(p) for p in f.read().split()]
    except (OSError, ValueError):
        return []


def proc_comm(pid):
    try:
        with open("/proc/%d/comm" % pid) as f:
            return f.read().strip()
    except OSError:
        return ""


def find_editor_proc(shell_pid, depth=3):
    """BFS over the shell's descendants for a vim/nano process. Returns
    (pid, 'vim'|'nano') or None. Read-only /proc walks, never raises."""
    if not shell_pid:
        return None
    frontier = [int(shell_pid)]
    for _ in range(depth):
        nxt = []
        for p in frontier:
            for child in proc_children(p):
                kind = editor_kind(proc_comm(child))
                if kind:
                    return (child, kind)
                nxt.append(child)
        frontier = nxt
        if not frontier:
            break
    return None


# ---- F9 ledger -------------------------------------------------------------
def ledger_path():
    return os.path.join(sc.config_dir(), "ledger.json")


def ledger_load(path=None):
    try:
        with open(path or ledger_path()) as f:
            d = json.load(f)
        if isinstance(d, dict) and isinstance(d.get("days"), dict):
            d.setdefault("local_fixes", 0)
            return d
    except (OSError, ValueError):
        pass
    return {"version": 1, "days": {}, "local_fixes": 0}


def ledger_save(data, path=None):
    try:
        sc.atomic_write_json(path or ledger_path(), data)
    except OSError:
        pass


def _day_key(now=None):
    return time.strftime("%Y-%m-%d", time.localtime(now))


def ledger_add(data, mode, est, now=None):
    """Record one ask's token estimate under today's bucket (trimmed)."""
    day = data["days"].setdefault(_day_key(now), {"total": 0, "asks": []})
    day["total"] = int(day.get("total") or 0) + int(est)
    day["asks"] = (day.get("asks") or [])[-199:] + [
        {"ts": now if now is not None else time.time(),
         "mode": mode, "est": int(est)}
    ]
    for stale in sorted(data["days"])[:-60]:
        del data["days"][stale]
    return data


def ledger_today(data, now=None):
    day = data.get("days", {}).get(_day_key(now)) or {}
    try:
        return int(day.get("total") or 0)
    except (TypeError, ValueError):
        return 0


def ledger_recent(data, n=10):
    asks = []
    for day in data.get("days", {}).values():
        for a in day.get("asks") or []:
            if isinstance(a, dict):
                asks.append(a)
    asks.sort(key=lambda a: a.get("ts") or 0, reverse=True)
    return asks[:n]


# ---- F7 session ------------------------------------------------------------
def load_session(path=None):
    try:
        with open(path or session_file()) as f:
            d = json.load(f)
        if isinstance(d, dict) and isinstance(d.get("tabs"), list):
            return d
    except (OSError, ValueError):
        pass
    return None


def save_session(sess, path=None):
    try:
        sc.atomic_write_json(path or session_file(), sess)
    except OSError:
        pass


def restore_offer(sess, home=None):
    """Offer restore unless the previous exit was a clean single-tab session
    sitting at the default cwd (nothing worth resurrecting)."""
    if not sess:
        return False
    tabs = [t for t in sess.get("tabs") or [] if isinstance(t, dict)]
    if not tabs:
        return False
    if sess.get("clean") and len(tabs) == 1:
        home = home or os.path.expanduser("~")
        cwd = tabs[0].get("cwd") or home
        try:
            if os.path.abspath(os.path.expanduser(cwd)) == home:
                return False
        except (OSError, ValueError):
            return False
    return True


def prune_tab_dirs(root=None, max_age_days=TAB_RETENTION_DAYS, keep=(),
                   now=None):
    """Delete persisted tab dirs older than the retention window."""
    root = root or tabs_root()
    cutoff = (now if now is not None else time.time()) - max_age_days * 86400
    removed = []
    try:
        names = os.listdir(root)
    except OSError:
        return removed
    for name in names:
        p = os.path.join(root, name)
        if name in keep or not os.path.isdir(p):
            continue
        try:
            if os.path.getmtime(p) < cutoff:
                shutil.rmtree(p, ignore_errors=True)
                removed.append(name)
        except OSError:
            continue
    return removed


# ---- F4 sentry -------------------------------------------------------------
def sentry_spec(verdict):
    """Map a guard.classify verdict (or None) to the result-row presentation.
    Pure function so the trust-critical mapping is unit-testable."""
    v = verdict if isinstance(verdict, dict) else {}
    level = str(v.get("level") or v.get("class") or "unknown")
    level = {"read-only": "read", "mutating": "mutate"}.get(level, level)
    reasons = [str(r) for r in (v.get("reasons") or []) if r]
    effect = str(v.get("effect") or "") or "; ".join(reasons[:2])
    priv = " · SUDO" if v.get("privileged") else ""
    spec = {
        "level": level, "chip": "UNVERIFIED", "chip_class": "",
        "row_class": "", "run": True, "type_label": "TYPE",
        "type_sensitive": True, "effect_line": "", "tooltip": effect,
    }
    if level in ("safe", "read"):
        spec.update(chip="✓ READ" + priv, chip_class="ok",
                    row_class="risk-low")
    elif level == "mutate":
        spec.update(chip="MUTATE" + priv, chip_class="warn",
                    row_class="risk-med")
    elif level == "destructive":
        spec.update(chip="DESTRUCTIVE" + priv, chip_class="err",
                    row_class="risk-high", run=False,
                    type_label="INSERT (GUARDED)",
                    effect_line="→ " + (effect or "flagged as destructive — "
                                        "the shell guard confirms at Enter"))
    elif level == "block":
        spec.update(chip="BLOCKED", chip_class="err", row_class="risk-high",
                    run=False, type_label="BLOCKED", type_sensitive=False,
                    effect_line="insertion disabled: "
                                + (effect or "protected operation"))
    return spec


class SpliceWindow(Gtk.Window):
    def __init__(self, cwd=None, command=None):
        super().__init__(title="SPLICE")
        self.set_default_size(1180, 720)
        self.settings = sc.load_settings()
        self.font_desc = Pango.FontDescription.from_string(
            self.settings.get("font") or "Monospace 11.5"
        )
        self.font_scale = 1.0
        self._chat_has_content = False
        self.ctx_tokens = 0
        self.attach_text = None
        self._armed_send = None
        self._persist_pending = False
        self._pending_restore = None
        self.ledger = ledger_load()
        # F7: session persistence is off for -e command windows entirely
        self.session_enabled = bool(
            self.settings.get("restore_tabs", True)) and not command
        prev = load_session() if self.session_enabled else None

        self._load_css()
        self._set_icon()
        self._build_header()
        self._build_body()
        self._install_keys()

        self.connect("destroy", Gtk.main_quit)
        self.connect("delete-event", self._on_delete)
        self.new_tab(cwd=cwd, command=command)
        self.show_all()
        # start hidden
        self.ai_revealer.set_reveal_child(False)
        self.ctx_revealer.set_reveal_child(False)
        self.restore_rev.set_reveal_child(False)
        self.ai_result_box.hide()
        self.chat_scroll.hide()
        self.ai_status.hide()
        self.spinner.hide()
        self.attach_box.hide()
        self.effect_label.hide()

        if prev and restore_offer(prev):
            self._pending_restore = prev
            n = len(prev.get("tabs") or [])
            self.restore_label.set_text(
                "⌁ restore %d tab%s from last session?   [Enter] restore · "
                "[Esc] fresh" % (n, "" if n == 1 else "s"))
            self.restore_rev.set_reveal_child(True)
        if self.session_enabled:
            self._schedule_persist()

        # background: rescan contexts once, then populate sidebar + chip
        threading.Thread(target=self._startup_scan, daemon=True).start()

    # ------------------------------------------------------------- chrome --
    def _load_css(self):
        Gtk.Settings.get_default().set_property(
            "gtk-application-prefer-dark-theme", True
        )
        provider = Gtk.CssProvider()
        css_path = os.path.join(SHARE, "style.css")
        try:
            provider.load_from_path(css_path)
        except GLib.Error as e:
            print("splice: style.css failed to load: %s" % e, file=sys.stderr)
            return
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
        )

    def _set_icon(self):
        icon = os.path.normpath(
            os.path.join(
                SHARE, "..", "icons", "hicolor", "scalable", "apps", "splice.svg"
            )
        )
        try:
            if os.path.isfile(icon):
                self.set_icon_from_file(icon)
            else:
                self.set_icon_name("utilities-terminal")
        except GLib.Error:
            pass

    def _build_header(self):
        hb = Gtk.HeaderBar()
        hb.set_show_close_button(True)
        hb.get_style_context().add_class("splice-header")

        # wordmark: SPLICE ⌁
        mark = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        w = Gtk.Label(label="SPLICE")
        w.get_style_context().add_class("wordmark")
        bolt = Gtk.Label(label="⌁")
        bolt.get_style_context().add_class("wordmark-bolt")
        mark.pack_start(w, False, False, 0)
        mark.pack_start(bolt, False, False, 0)
        hb.set_custom_title(mark)

        btn_new = Gtk.Button(label="+")
        btn_new.get_style_context().add_class("hbtn")
        btn_new.set_tooltip_text("New tab (Ctrl+Shift+T)")
        btn_new.connect("clicked", lambda *_: self.new_tab())
        hb.pack_start(btn_new)

        btn_ask = Gtk.Button(label="⌁ ASK")
        btn_ask.get_style_context().add_class("accent")
        btn_ask.set_tooltip_text("AI command bar (Ctrl+Space)")
        btn_ask.connect("clicked", lambda *_: self.toggle_ai_bar())
        hb.pack_start(btn_ask)

        # F9: the token chip is now the session ledger (click = breakdown)
        self.token_chip = Gtk.Label(label="⌁ ~0 tok")
        self.ledger_btn = Gtk.MenuButton()
        self.ledger_btn.add(self.token_chip)
        self.ledger_btn.get_style_context().add_class("chipbtn")
        self.ledger_pop = Gtk.Popover()
        self.ledger_pop.get_style_context().add_class("splice-menu")
        self.ledger_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
                                  spacing=2)
        self.ledger_box.set_border_width(8)
        self.ledger_pop.add(self.ledger_box)
        self.ledger_btn.set_popover(self.ledger_pop)
        hb.pack_end(self._build_menu())
        hb.pack_end(self.ledger_btn)

        btn_ctx = Gtk.Button(label="CTX")
        btn_ctx.get_style_context().add_class("hbtn")
        btn_ctx.set_tooltip_text("Context sidebar (Ctrl+B)")
        btn_ctx.connect("clicked", lambda *_: self.toggle_sidebar())
        hb.pack_end(btn_ctx)

        self.set_titlebar(hb)
        self._update_ledger_chip()

    def _build_menu(self):
        btn = Gtk.MenuButton(label="≡")
        btn.get_style_context().add_class("hbtn")
        pop = Gtk.Popover()
        pop.get_style_context().add_class("splice-menu")
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        box.set_border_width(8)

        fr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        fl = Gtk.Label(label="Font size")
        fl.set_xalign(0)
        fl.get_style_context().add_class("dim")
        minus = Gtk.Button(label="−")
        minus.get_style_context().add_class("hbtn")
        minus.connect("clicked", lambda *_: self.font_step(-1))
        plus = Gtk.Button(label="+")
        plus.get_style_context().add_class("hbtn")
        plus.connect("clicked", lambda *_: self.font_step(+1))
        fr.pack_start(fl, True, True, 0)
        fr.pack_start(minus, False, False, 0)
        fr.pack_start(plus, False, False, 0)
        box.pack_start(fr, False, False, 0)

        sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
        box.pack_start(sep, False, False, 2)

        about = Gtk.ModelButton(label="About SPLICE")
        about.connect("clicked", self._show_about)
        box.pack_start(about, False, False, 0)

        box.show_all()
        pop.add(box)
        btn.set_popover(pop)
        return btn

    def _show_about(self, *_):
        d = Gtk.AboutDialog(transient_for=self, modal=True)
        d.set_program_name("SPLICE ⌁")
        d.set_version("1.3.0")
        d.set_comments(
            "AI-native terminal.\n"
            "Typo rescue · Ctrl+Space ask · Ctrl+B contexts.\n"
            "Local first, tokens last."
        )
        d.set_copyright("Hank Elsner · hankelsner.tech")
        d.set_license_type(Gtk.License.MIT_X11)
        d.run()
        d.destroy()

    # --------------------------------------------------------------- body --
    def _build_body(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.add(outer)

        # F7 restore bar (top-docked plate; Enter/Esc handled in _on_key)
        self.restore_rev = Gtk.Revealer()
        self.restore_rev.set_transition_type(
            Gtk.RevealerTransitionType.SLIDE_DOWN)
        self.restore_rev.set_transition_duration(140)
        rbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        rbox.get_style_context().add_class("restore-bar")
        rbox.set_border_width(6)
        self.restore_label = Gtk.Label(label="")
        self.restore_label.set_xalign(0)
        rbox.pack_start(self.restore_label, True, True, 4)
        self.restore_rev.add(rbox)
        outer.pack_start(self.restore_rev, False, False, 0)

        self._build_ai_bar()
        outer.pack_start(self.ai_revealer, False, False, 0)

        paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
        outer.pack_start(paned, True, True, 0)

        self.notebook = Gtk.Notebook()
        self.notebook.set_scrollable(True)
        self.notebook.get_style_context().add_class("splice-tabs")
        paned.pack1(self.notebook, True, False)

        self._build_sidebar()
        paned.pack2(self.ctx_revealer, False, True)

    # -------------------------------------------------------------- AI bar --
    def _build_ai_bar(self):
        self.ai_revealer = Gtk.Revealer()
        self.ai_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        self.ai_revealer.set_transition_duration(140)

        bar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        bar.get_style_context().add_class("aibar")
        bar.set_border_width(8)
        self.aibar_box = bar
        self.ai_revealer.add(bar)

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        bar.pack_start(row, False, False, 0)

        modes = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        modes.get_style_context().add_class("linked")
        self.mode_cmd = Gtk.ToggleButton(label="CMD")
        self.mode_cmd.get_style_context().add_class("mode-toggle")
        self.mode_cmd.set_active(True)
        self.mode_chat = Gtk.ToggleButton(label="CHAT")
        self.mode_chat.get_style_context().add_class("mode-toggle")
        self.mode_cmd.connect("toggled", self._on_mode, "cmd")
        self.mode_chat.connect("toggled", self._on_mode, "chat")
        modes.pack_start(self.mode_cmd, False, False, 0)
        modes.pack_start(self.mode_chat, False, False, 0)
        row.pack_start(modes, False, False, 0)

        self.ai_entry = Gtk.Entry()
        self.ai_entry.set_placeholder_text(
            "describe the command you need … (Enter)"
        )
        self.ai_entry.connect("activate", self._on_ai_submit)
        self.ai_entry.connect("changed", lambda *_: self._disarm_send())
        row.pack_start(self.ai_entry, True, True, 0)

        self.spinner = Gtk.Spinner()
        row.pack_start(self.spinner, False, False, 0)

        close = Gtk.Button(label="✕")
        close.get_style_context().add_class("hbtn")
        close.connect("clicked", lambda *_: self.toggle_ai_bar(False))
        row.pack_start(close, False, False, 0)

        # F5 attachment chip: visible, one-click removable — nothing invisible
        self.attach_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
                                  spacing=6)
        self.attach_box.get_style_context().add_class("attach-chip")
        self.attach_box.set_no_show_all(True)
        self.attach_label = Gtk.Label(label="")
        self.attach_label.set_xalign(0)
        self.attach_label.set_ellipsize(Pango.EllipsizeMode.END)
        ax = Gtk.Button(label="✕")
        ax.get_style_context().add_class("tab-close")
        ax.set_tooltip_text("Remove attachment")
        ax.connect("clicked", lambda *_: self._set_attachment(None))
        self.attach_box.pack_start(self.attach_label, True, True, 4)
        self.attach_box.pack_start(ax, False, False, 0)
        bar.pack_start(self.attach_box, False, False, 0)

        self.ai_status = Gtk.Label(label="")
        self.ai_status.set_xalign(0)
        self.ai_status.get_style_context().add_class("status-label")
        bar.pack_start(self.ai_status, False, False, 0)

        # CMD result row (vertical: command row + F4 effect line)
        self.ai_result_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
                                     spacing=4)
        self.ai_result_box.get_style_context().add_class("result-row")
        row1 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        pr = Gtk.Label(label="$")
        pr.get_style_context().add_class("result-prompt")
        row1.pack_start(pr, False, False, 4)
        self.result_label = Gtk.Label(label="")
        self.result_label.set_xalign(0)
        self.result_label.set_selectable(True)
        self.result_label.set_line_wrap(True)
        self.result_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
        self.result_label.get_style_context().add_class("cmdtext")
        row1.pack_start(self.result_label, True, True, 0)
        # F4 sentry chip: classification verdict before anything is offered
        self.sentry_chip = Gtk.Label(label="")
        self.sentry_chip.get_style_context().add_class("sentry-chip")
        self.sentry_chip.set_valign(Gtk.Align.CENTER)
        row1.pack_start(self.sentry_chip, False, False, 0)
        self.btn_run = Gtk.Button(label="RUN")
        self.btn_run.get_style_context().add_class("accent")
        self.btn_run.connect("clicked", self._on_result_run)
        self.btn_type = Gtk.Button(label="TYPE")
        self.btn_type.get_style_context().add_class("hbtn")
        self.btn_type.connect("clicked", self._on_result_type)
        btn_dis = Gtk.Button(label="DISMISS")
        btn_dis.get_style_context().add_class("hbtn")
        btn_dis.connect("clicked", lambda *_: self.ai_result_box.hide())
        for b in (self.btn_run, self.btn_type, btn_dis):
            row1.pack_start(b, False, False, 0)
        self.ai_result_box.pack_start(row1, False, False, 0)
        self.effect_label = Gtk.Label(label="")
        self.effect_label.set_xalign(0)
        self.effect_label.set_line_wrap(True)
        self.effect_label.set_no_show_all(True)
        self.effect_label.get_style_context().add_class("effect-line")
        self.ai_result_box.pack_start(self.effect_label, False, False, 0)
        bar.pack_start(self.ai_result_box, False, False, 0)

        # CHAT answer view
        self.chat_scroll = Gtk.ScrolledWindow()
        self.chat_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        if hasattr(self.chat_scroll, "set_propagate_natural_height"):
            self.chat_scroll.set_propagate_natural_height(True)
            self.chat_scroll.set_max_content_height(288)
        else:
            self.chat_scroll.set_min_content_height(200)
        self.chat_view = Gtk.TextView()
        self.chat_view.set_editable(False)
        self.chat_view.set_cursor_visible(False)
        self.chat_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        self.chat_view.get_style_context().add_class("chatview")
        self.chat_view.set_left_margin(8)
        self.chat_view.set_right_margin(8)
        self.chat_view.set_top_margin(6)
        self.chat_view.set_bottom_margin(6)
        self.chat_scroll.add(self.chat_view)
        bar.pack_start(self.chat_scroll, False, False, 0)

        self.connect("size-allocate", self._on_size_allocate)

    def _on_size_allocate(self, _w, alloc):
        if hasattr(self.chat_scroll, "set_max_content_height"):
            self.chat_scroll.set_max_content_height(max(120, int(alloc.height * 0.4)))

    def _on_mode(self, btn, which):
        if not btn.get_active():
            # never allow both off — re-activate the other's inverse
            if not self.mode_cmd.get_active() and not self.mode_chat.get_active():
                btn.set_active(True)
            return
        other = self.mode_chat if which == "cmd" else self.mode_cmd
        other.handler_block_by_func(self._on_mode)
        other.set_active(False)
        other.handler_unblock_by_func(self._on_mode)
        self._disarm_send()
        if which == "cmd":
            self.ai_entry.set_placeholder_text(
                "describe the command you need … (Enter)"
            )
            self.chat_scroll.hide()
        else:
            self.ai_entry.set_placeholder_text("ask anything … (Enter)")
            self.ai_result_box.hide()
            if self._chat_has_content:
                self.chat_scroll.show()
        self.ai_entry.grab_focus()

    def toggle_ai_bar(self, show=None):
        cur = self.ai_revealer.get_reveal_child()
        show = (not cur) if show is None else show
        self.ai_revealer.set_reveal_child(show)
        ctx = self.aibar_box.get_style_context()
        if show:
            ctx.add_class("active")
            self.ai_entry.grab_focus()
        else:
            ctx.remove_class("active")
            self._set_attachment(None)  # nothing invisible survives the bar
            self._disarm_send()
            t = self.current_term()
            if t:
                t.grab_focus()

    def _set_status(self, text, kind=""):
        ctx = self.ai_status.get_style_context()
        for c in ("status-err", "status-ok", "status-warn"):
            ctx.remove_class(c)
        if kind:
            ctx.add_class("status-" + kind)
        if text:
            self.ai_status.set_text(text)
            self.ai_status.show()
        else:
            self.ai_status.hide()

    def _disarm_send(self):
        self._armed_send = None

    # ---- F5 attachment ------------------------------------------------
    def _set_attachment(self, text, desc=None):
        self.attach_text = text or None
        if not text:
            self.attach_box.hide()
            return
        est = sc.est_tokens(text)
        self.attach_label.set_text(
            "▣ %s · %s tok" % (desc or "attachment", fmt_tokens(est)))
        self.attach_box.set_no_show_all(False)
        self.attach_box.show_all()
        self.attach_box.set_no_show_all(True)

    def ask_about_last(self, page=None):
        """Ctrl+Shift+A / rescue-strip [A]: ask bar with the last command +
        output + exit code attached (visible chip, F5)."""
        page = page or self.current_page()
        if not page:
            return
        cmd = getattr(page, "last_cmd", "") or ""
        out = getattr(page, "last_output", "") or ""
        code = getattr(page, "last_exit", 0)
        if not cmd and not out:
            # fall back to the last visible lines — still explicit, still shown
            term = getattr(page, "splice_term", None)
            if term:
                out = self._capture_tail(term, 40)
            if not out:
                self.toggle_ai_bar(True)
                self._set_status("nothing captured yet — run a command first",
                                 "warn")
                return
        att = "$ %s\n[exit %s]\n%s" % (cmd or "(unknown)", code, out)
        nlines = len(out.splitlines())
        desc = "last cmd + %d line%s" % (nlines, "" if nlines == 1 else "s")
        self.toggle_ai_bar(True)
        self._set_attachment(att, desc)

    def ask_about_selection(self, term=None):
        term = term or self.current_term()
        if not term:
            return
        text = self._selection_text(term)
        if not text:
            return
        nlines = len(text.splitlines())
        self.toggle_ai_bar(True)
        self._set_attachment(
            text, "selection · %d line%s" % (nlines, "" if nlines == 1 else "s"))

    def _selection_text(self, term):
        try:
            t = term.get_text_selected(Vte.Format.TEXT)
            if t:
                return t
        except (AttributeError, TypeError):
            pass
        try:
            clip = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY)
            return clip.wait_for_text() or ""
        except GLib.Error:
            return ""

    # ---- ask submit (F5 attach + F9 estimate/budget) --------------------
    def _on_ai_submit(self, entry):
        q = entry.get_text().strip()
        if not q and self.attach_text:
            q = "Explain this failure and give the exact fix command."
        if not q:
            return
        if not sc.claude_available():
            self._set_status(
                "claude CLI not found — install/login first "
                "(terminal + local rescues still work)",
                "err",
            )
            return
        mode = "cmd" if self.mode_cmd.get_active() else "chat"
        est = sc.est_tokens(q) + self.ctx_tokens
        if self.attach_text:
            est += sc.est_tokens(self.attach_text)
        try:
            thresh = int(self.settings.get("big_send_tokens")
                         or BIG_SEND_TOKENS_DEFAULT)
        except (TypeError, ValueError):
            thresh = BIG_SEND_TOKENS_DEFAULT
        if est > thresh and self._armed_send != q:
            self._armed_send = q
            self._set_status(
                "⚠ large send: %s tok estimated — press Enter again to "
                "confirm" % fmt_tokens(est), "err")
            return
        self._armed_send = None
        self._set_status("")
        # F9: record the estimate at send time
        self.ledger = ledger_add(self.ledger, mode, est)
        ledger_save(self.ledger)
        self._update_ledger_chip()
        try:
            budget = int(self.settings.get("daily_budget_tokens") or 0)
        except (TypeError, ValueError):
            budget = 0
        if budget and ledger_today(self.ledger) > budget:
            self._set_status(
                "over daily budget (%s / %s tok today) — sending anyway"
                % (fmt_tokens(ledger_today(self.ledger)), fmt_tokens(budget)),
                "warn")
        self.spinner.show()
        self.spinner.start()
        entry.set_sensitive(False)
        attach = self.attach_text
        self._set_attachment(None)  # one-shot
        threading.Thread(target=self._ai_worker, args=(mode, q, attach),
                         daemon=True).start()

    def _ai_worker(self, mode, q, attach=None):
        sub = "ask" if mode == "cmd" else "chat"
        argv = cli_cmd("splice-ai", sub, q)
        if attach:
            argv += ["--attach", "-"]
        rc, out, err = run_cli(argv, timeout=120, input_text=attach)
        verdict = None
        cmdline = out.strip()
        if mode == "cmd" and rc == 0 and cmdline:
            # F4 sentry: classify BEFORE the command is offered (local, 0 tok)
            vrc, vout, _verr = run_cli(
                cli_cmd("splice-ai", "classify", "-"),
                timeout=10, input_text=cmdline)
            if vout.strip():
                try:
                    verdict = json.loads(vout)
                except ValueError:
                    verdict = None
        GLib.idle_add(self._ai_done, mode, q, rc, cmdline, err.strip(), verdict)

    def _ai_done(self, mode, q, rc, out, err, verdict=None):
        self.spinner.stop()
        self.spinner.hide()
        self.ai_entry.set_sensitive(True)
        if rc != 0 or not out:
            msg = err.splitlines()[-1] if err else "no answer — try rephrasing"
            self._set_status(msg, "err")
            self.ai_entry.grab_focus()
            return False
        if mode == "cmd":
            self.result_label.set_text(out)
            self.ai_result_box.show_all()
            self._apply_sentry(verdict)
            if self.btn_type.get_sensitive():
                self.btn_type.grab_focus()  # Enter defaults to TYPE/insert
        else:
            buf = self.chat_view.get_buffer()
            prefix = "" if buf.get_char_count() == 0 else "\n\n"
            buf.insert(
                buf.get_end_iter(), "%s⌁ %s\n%s" % (prefix, q, out)
            )
            self._chat_has_content = True
            self.chat_scroll.show()
            GLib.idle_add(self._chat_scroll_to_end)
            self.ai_entry.set_text("")
            self.ai_entry.grab_focus()
            self._schedule_persist()
        return False

    def _apply_sentry(self, verdict):
        """F4: dress the result row per the guard verdict. Insertion is the
        ceiling either way — the bash-side guard is the independent 2nd gate."""
        spec = sentry_spec(verdict)
        rowctx = self.ai_result_box.get_style_context()
        for c in ("risk-low", "risk-med", "risk-high"):
            rowctx.remove_class(c)
        if spec["row_class"]:
            rowctx.add_class(spec["row_class"])
        chipctx = self.sentry_chip.get_style_context()
        for c in ("ok", "warn", "err"):
            chipctx.remove_class(c)
        if spec["chip_class"]:
            chipctx.add_class(spec["chip_class"])
        self.sentry_chip.set_text(spec["chip"])
        self.sentry_chip.set_tooltip_text(spec["tooltip"] or None)
        self.btn_run.set_visible(spec["run"])
        self.btn_type.set_label(spec["type_label"])
        self.btn_type.set_sensitive(spec["type_sensitive"])
        if spec["effect_line"]:
            self.effect_label.set_text(spec["effect_line"])
            self.effect_label.show()
        else:
            self.effect_label.hide()

    def _chat_scroll_to_end(self):
        buf = self.chat_view.get_buffer()
        self.chat_view.scroll_to_iter(buf.get_end_iter(), 0.0, False, 0, 0)
        return False

    def _result_cmd(self):
        return self.result_label.get_text().strip()

    def _on_result_run(self, *_):
        cmd = self._result_cmd()
        if cmd:
            self.feed_active(cmd + "\n")
        self.ai_result_box.hide()
        self.ai_entry.set_text("")
        self.toggle_ai_bar(False)

    def _on_result_type(self, *_):
        cmd = self._result_cmd()
        if cmd:
            self.feed_active(cmd)
        self.ai_result_box.hide()
        self.ai_entry.set_text("")
        self.toggle_ai_bar(False)

    # ------------------------------------------------------------ sidebar --
    def _build_sidebar(self):
        self.ctx_revealer = Gtk.Revealer()
        self.ctx_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_LEFT)
        self.ctx_revealer.set_transition_duration(140)

        side = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        side.get_style_context().add_class("sidebar")
        side.set_size_request(310, -1)
        self.ctx_revealer.add(side)

        head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        head.get_style_context().add_class("sidebar-head")
        head.set_border_width(8)
        title = Gtk.Label(label="CONTEXTS")
        title.set_xalign(0)
        title.get_style_context().add_class("sidebar-title")
        head.pack_start(title, True, True, 0)
        self.btn_auto = Gtk.Button(label="AUTO")
        self.btn_auto.get_style_context().add_class("accent")
        self.btn_auto.set_tooltip_text(
            "Let the AI pick relevant contexts (your pins survive)"
        )
        self.btn_auto.connect("clicked", self._on_auto)
        self.btn_rescan = Gtk.Button(label="RESCAN")
        self.btn_rescan.get_style_context().add_class("hbtn")
        self.btn_rescan.set_tooltip_text("Re-discover memory + project files")
        self.btn_rescan.connect("clicked", self._on_rescan)
        head.pack_start(self.btn_auto, False, False, 0)
        head.pack_start(self.btn_rescan, False, False, 0)
        side.pack_start(head, False, False, 0)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        self.ctx_list = Gtk.ListBox()
        self.ctx_list.set_selection_mode(Gtk.SelectionMode.NONE)
        self.ctx_list.get_style_context().add_class("ctx-list")
        sw.add(self.ctx_list)
        side.pack_start(sw, True, True, 0)

        self.ctx_footer = Gtk.Label(label="Σ ~0 tok")
        self.ctx_footer.set_xalign(0)
        self.ctx_footer.get_style_context().add_class("footer")
        side.pack_start(self.ctx_footer, False, False, 0)

    def toggle_sidebar(self, show=None):
        cur = self.ctx_revealer.get_reveal_child()
        show = (not cur) if show is None else show
        self.ctx_revealer.set_reveal_child(show)
        if show:
            self.refresh_contexts()
        else:
            t = self.current_term()
            if t:
                t.grab_focus()

    def _startup_scan(self):
        # F7 housekeeping: retention prune (spare anything restorable/live)
        try:
            keep = set()
            if self._pending_restore:
                keep |= {str(t.get("id")) for t in
                         self._pending_restore.get("tabs") or []
                         if isinstance(t, dict)}
            for page in self.notebook.get_children():
                keep.add(getattr(page, "splice_uuid", ""))
            prune_tab_dirs(keep=keep)
            # stale FIFOs from dead sessions
            cutoff = time.time() - 86400
            for name in os.listdir(run_dir()):
                p = os.path.join(run_dir(), name)
                try:
                    if os.path.getmtime(p) < cutoff:
                        os.unlink(p)
                except OSError:
                    pass
        except OSError:
            pass
        run_cli(cli_cmd("splice-ctx", "scan"), timeout=60)
        self._list_worker()

    def refresh_contexts(self):
        threading.Thread(target=self._list_worker, daemon=True).start()

    def _list_worker(self):
        rc, out, err = run_cli(cli_cmd("splice-ctx", "list", "--json"), timeout=30)
        contexts, total = [], 0
        if rc == 0 and out.strip():
            try:
                data = json.loads(out)
                if isinstance(data, dict):
                    contexts = data.get("contexts", [])
                    total = data.get("total_enabled_tokens", 0)
                elif isinstance(data, list):
                    contexts = data
                    total = sum(
                        int(c.get("tokens_est") or 0)
                        for c in contexts
                        if isinstance(c, dict) and c.get("enabled")
                    )
            except (ValueError, TypeError):
                pass
        GLib.idle_add(self._populate_contexts, contexts, total)

    def _populate_contexts(self, contexts, total):
        for child in self.ctx_list.get_children():
            self.ctx_list.remove(child)
        for c in contexts:
            if not isinstance(c, dict):
                continue
            self.ctx_list.add(self._make_ctx_row(c))
        self.ctx_list.show_all()
        self.ctx_footer.set_text(
            "Σ %s tok enabled · est. input cost of every ASK"
            % fmt_tokens(total)
        )
        try:
            self.ctx_tokens = int(total)
        except (TypeError, ValueError):
            self.ctx_tokens = 0
        self._update_ledger_chip()
        return False

    def _make_ctx_row(self, c):
        row = Gtk.ListBoxRow()
        row.set_activatable(False)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        box.set_border_width(6)
        row.add(box)

        top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        check = Gtk.CheckButton(label=str(c.get("title") or c.get("id") or "?"))
        check.set_active(bool(c.get("enabled")))
        check.get_child().set_ellipsize(Pango.EllipsizeMode.END)
        top.pack_start(check, True, True, 0)
        set_by = str(c.get("set_by") or "default")
        if set_by == "user":
            pin = Gtk.Label(label="PIN")
            pin.get_style_context().add_class("pin")
            pin.set_valign(Gtk.Align.CENTER)
            top.pack_start(pin, False, False, 0)
        box.pack_start(top, False, False, 0)

        sub_txt = "%st · %s · %s" % (
            fmt_tokens(c.get("tokens_est") or 0),
            str(c.get("kind") or "?"),
            set_by,
        )
        if c.get("missing"):
            sub_txt += " · missing"
        sub = Gtk.Label(label=sub_txt)
        sub.set_xalign(0)
        sub.set_margin_start(26)
        sub.get_style_context().add_class("meta")
        box.pack_start(sub, False, False, 0)

        path = c.get("path")
        if path:
            row.set_tooltip_text(str(path))
        # connect AFTER set_active so programmatic state doesn't fire the CLI
        check.connect("toggled", self._on_ctx_toggled, str(c.get("id") or ""))
        return row

    def _on_ctx_toggled(self, check, ctx_id):
        if not ctx_id:
            return
        verb = "enable" if check.get_active() else "disable"
        check.set_sensitive(False)
        threading.Thread(
            target=self._toggle_worker, args=(verb, ctx_id), daemon=True
        ).start()

    def _toggle_worker(self, verb, ctx_id):
        run_cli(cli_cmd("splice-ctx", verb, ctx_id, "--by", "user"), timeout=30)
        self._list_worker()

    def _on_auto(self, *_):
        self.btn_auto.set_sensitive(False)
        cwd = self.active_cwd()
        threading.Thread(target=self._auto_worker, args=(cwd,), daemon=True).start()

    def _auto_worker(self, cwd):
        run_cli(cli_cmd("splice-ctx", "auto", "--cwd", cwd), timeout=90)
        self._list_worker()
        GLib.idle_add(self.btn_auto.set_sensitive, True)

    def _on_rescan(self, *_):
        self.btn_rescan.set_sensitive(False)
        threading.Thread(target=self._rescan_worker, daemon=True).start()

    def _rescan_worker(self):
        run_cli(cli_cmd("splice-ctx", "scan"), timeout=60)
        self._list_worker()
        GLib.idle_add(self.btn_rescan.set_sensitive, True)

    # ---------------------------------------------------------- F9 ledger --
    def _update_ledger_chip(self):
        today = ledger_today(self.ledger)
        self.token_chip.set_text("⌁ %s tok" % fmt_tokens(today))
        self.ledger_btn.set_tooltip_text(
            "AI tokens today (estimated) — click for the ledger\n"
            "enabled contexts add %s tok to every ask" % fmt_tokens(self.ctx_tokens))
        for child in self.ledger_box.get_children():
            self.ledger_box.remove(child)
        head = Gtk.Label(label="TOKEN LEDGER · today %s tok est"
                         % fmt_tokens(today))
        head.set_xalign(0)
        head.get_style_context().add_class("sidebar-title")
        self.ledger_box.pack_start(head, False, False, 2)
        recent = ledger_recent(self.ledger, 10)
        if not recent:
            empty = Gtk.Label(label="no asks yet — everything so far was local")
            empty.set_xalign(0)
            empty.get_style_context().add_class("meta")
            self.ledger_box.pack_start(empty, False, False, 0)
        for a in recent:
            try:
                stamp = time.strftime("%H:%M", time.localtime(a.get("ts") or 0))
            except (ValueError, OSError, OverflowError):
                stamp = "--:--"
            lbl = Gtk.Label(label="%s  %-4s  %s tok" % (
                stamp, str(a.get("mode") or "?")[:4], fmt_tokens(a.get("est"))))
            lbl.set_xalign(0)
            lbl.get_style_context().add_class("meta")
            self.ledger_box.pack_start(lbl, False, False, 0)
        fixes = int(self.ledger.get("local_fixes") or 0)
        foot = Gtk.Label(label="saved by local: %d fix%s · 0 tok"
                         % (fixes, "" if fixes == 1 else "es"))
        foot.set_xalign(0)
        foot.get_style_context().add_class("ai-meta")
        foot.get_style_context().add_class("local")
        self.ledger_box.pack_start(foot, False, False, 4)
        self.ledger_box.show_all()

    # --------------------------------------------------------------- tabs --
    def new_tab(self, cwd=None, command=None, restore_text=None, title=None):
        term = Vte.Terminal()
        term.set_scrollback_lines(SCROLLBACK)
        term.set_mouse_autohide(True)
        term.set_font(self.font_desc)
        term.set_font_scale(self.font_scale)
        term.set_colors(rgba(FG), rgba(BG), [rgba(h) for h in PALETTE16])
        try:
            term.set_color_cursor(rgba(ACCENT))
            term.set_color_bold(rgba("#ffffff"))
        except AttributeError:
            pass
        try:
            # selection in the terminal carries the brand: orange on ink
            term.set_color_highlight(rgba(ACCENT))
            term.set_color_highlight_foreground(rgba(BG))
        except AttributeError:
            pass
        term.connect("child-exited", self._on_child_exited)
        term.connect("notify::current-directory-uri", self._on_cwd_changed)
        term.connect("button-press-event", self._on_term_button)

        page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        page.splice_term = term
        page.splice_cwd = cwd or self.active_cwd()
        page.splice_uuid = uuid.uuid4().hex
        page.splice_is_command = bool(command)
        page.shell_pid = 0
        # F1/F5 capture state
        page.fifo_fd = None
        page.fifo_path = None
        page.fifo_watch = None
        page.fifo_buf = b""
        page.mark_row = 0
        page.last_cmd = ""
        page.last_exit = 0
        page.last_output = ""
        # F1 rescue state
        page.rescue_seq = 0
        page.rescue_data = None
        page.rescue_timer = None
        # F6 explain state
        page.explain_cmd = ""
        # F8 editor state
        page.editor_poll = False
        page.editor_pid = 0
        page.editor_cmd = ""
        page.editor_deadline = 0.0

        page.rescue_rev = self._build_rescue_strip(page)
        page.pack_start(page.rescue_rev, False, False, 0)
        page.explain_rev = self._build_explain_card(page)
        page.pack_start(page.explain_rev, False, False, 0)

        body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        overlay = Gtk.Overlay()
        overlay.add(term)
        page.editor_rev = self._build_editor_overlay(page)
        overlay.add_overlay(page.editor_rev)
        body.pack_start(overlay, True, True, 0)
        sb = Gtk.Scrollbar(
            orientation=Gtk.Orientation.VERTICAL, adjustment=term.get_vadjustment()
        )
        body.pack_start(sb, False, False, 0)
        page.pack_start(body, True, True, 0)

        label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        lbl = Gtk.Label(label=title or os.path.basename(page.splice_cwd) or "/")
        lbl.set_ellipsize(Pango.EllipsizeMode.END)
        lbl.set_max_width_chars(18)
        close = Gtk.Button()
        close.set_relief(Gtk.ReliefStyle.NONE)
        close.set_focus_on_click(False)
        close.get_style_context().add_class("tab-close")
        close.add(
            Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.MENU)
        )
        close.connect("clicked", lambda *_: self._close_page(page))
        label_box.pack_start(lbl, True, True, 0)
        label_box.pack_start(close, False, False, 0)
        label_box.show_all()
        page.splice_label = lbl

        idx = self.notebook.append_page(page, label_box)
        self.notebook.set_tab_reorderable(page, True)
        page.show_all()
        page.rescue_rev.set_reveal_child(False)
        page.explain_rev.set_reveal_child(False)
        page.editor_rev.set_reveal_child(False)
        self.notebook.set_current_page(idx)

        # F7: greyed saved scrollback, fed BEFORE the shell spawns
        if restore_text:
            dim = ("\x1b[2m"
                   + restore_text.replace("\n", "\r\n")
                   + "\x1b[0m\r\n\x1b[2m── restored ──\x1b[0m\r\n")
            self._term_feed(term, dim)

        fifo_path = None
        if not command:
            fifo_path = self._setup_fifo(page)
        self._spawn_shell(term, page.splice_cwd, command, fifo_path)
        term.grab_focus()
        return page

    def _term_feed(self, term, text):
        data = text.encode()
        try:
            term.feed(data)
        except TypeError:
            term.feed(text, len(data))

    def _child_env(self, fifo_path=None):
        env = os.environ.copy()
        env["SPLICE"] = "1"
        env["SPLICE_SHARE"] = SHARE
        env["TERM"] = "xterm-256color"
        if fifo_path:
            env["SPLICE_EXITPIPE"] = fifo_path
        else:
            env.pop("SPLICE_EXITPIPE", None)
        return ["%s=%s" % (k, v) for k, v in env.items()]

    def _spawn_shell(self, term, cwd, command=None, fifo_path=None):
        if command:
            argv = list(command)
        else:
            argv = ["/bin/bash", "--rcfile", os.path.join(SHARE, "splice.bashrc"), "-i"]
        envv = self._child_env(fifo_path)
        if not os.path.isdir(cwd):
            cwd = os.path.expanduser("~")
        try:
            term.spawn_async(
                Vte.PtyFlags.DEFAULT,
                cwd,
                argv,
                envv,
                GLib.SpawnFlags.DEFAULT,
                None,
                None,
                -1,
                None,
                self._on_spawned,
                None,
            )
        except TypeError:
            # older VTE gi without the 11-arg spawn_async
            term.spawn_sync(
                Vte.PtyFlags.DEFAULT,
                cwd,
                argv,
                envv,
                GLib.SpawnFlags.DEFAULT,
                None,
                None,
                None,
            )

    def _on_spawned(self, term, pid, error, _data):
        if error is not None:
            msg = "splice: failed to start shell: %s\r\n" % error.message
            term.feed(msg.encode())
            return
        for page in self.notebook.get_children():
            if getattr(page, "splice_term", None) is term:
                page.shell_pid = pid
                return

    def _on_child_exited(self, term, _status):
        for page in self.notebook.get_children():
            if getattr(page, "splice_term", None) is term:
                self._close_page(page, from_exit=True)
                return

    def _close_page(self, page, from_exit=False):
        self._teardown_fifo(page)
        if page.rescue_timer:
            GLib.source_remove(page.rescue_timer)
            page.rescue_timer = None
        idx = self.notebook.page_num(page)
        was_current = idx == self.notebook.get_current_page()
        if idx >= 0:
            self.notebook.remove_page(idx)
            page.destroy()
        if self.notebook.get_n_pages() == 0:
            if self.session_enabled:
                self._persist_all(clean=True)  # deliberate: fresh next launch
            Gtk.main_quit()
        elif not from_exit or was_current:
            # refocus only when the closed tab was the one in front — a
            # background tab exiting must not yank focus from the ask bar
            if not (self.ai_revealer.get_reveal_child()
                    and self.ai_entry.has_focus()):
                t = self.current_term()
                if t:
                    t.grab_focus()

    def _on_cwd_changed(self, term, _pspec):
        uri = term.get_current_directory_uri()
        if not uri:
            return
        try:
            path, _host = GLib.filename_from_uri(uri)
        except GLib.Error:
            return
        for page in self.notebook.get_children():
            if getattr(page, "splice_term", None) is term:
                page.splice_cwd = path
                page.splice_label.set_text(os.path.basename(path) or "/")
                return

    def current_page(self):
        idx = self.notebook.get_current_page()
        if idx < 0:
            return None
        return self.notebook.get_nth_page(idx)

    def current_term(self):
        page = self.current_page()
        return getattr(page, "splice_term", None) if page else None

    def active_cwd(self):
        page = self.current_page()
        cwd = getattr(page, "splice_cwd", None) if page else None
        return cwd or os.path.expanduser("~")

    def feed_active(self, text):
        term = self.current_term()
        if not term:
            return
        data = text.encode()
        try:
            term.feed_child(data)
        except TypeError:
            term.feed_child(text, len(data))
        term.grab_focus()

    # ------------------------------------------------- F1 FIFO exit events --
    def _setup_fifo(self, page):
        path = os.path.join(run_dir(), page.splice_uuid)
        try:
            os.mkfifo(path, 0o600)
            # O_RDWR (not O_RDONLY): we count as a writer ourselves, so the
            # watch never sees POLLHUP — a reader-only FIFO reports HUP the
            # whole time no writer exists (before the shell opens it and
            # after it exits) and a HUP-armed watch busy-spins, starving
            # GTK redraw. Verified the hard way.
            fd = os.open(path, os.O_RDWR | os.O_NONBLOCK)
        except OSError:
            return None  # degrade: no rescue strip, terminal untouched
        page.fifo_fd = fd
        page.fifo_path = path
        page.fifo_watch = GLib.io_add_watch(
            fd, GLib.PRIORITY_DEFAULT, GLib.IOCondition.IN,
            self._on_fifo, page)
        return path

    def _teardown_fifo(self, page):
        if getattr(page, "fifo_watch", None):
            GLib.source_remove(page.fifo_watch)
            page.fifo_watch = None
        if getattr(page, "fifo_fd", None) is not None:
            try:
                os.close(page.fifo_fd)
            except OSError:
                pass
            page.fifo_fd = None
        if getattr(page, "fifo_path", None):
            try:
                os.unlink(page.fifo_path)
            except OSError:
                pass
            page.fifo_path = None

    def _on_fifo(self, fd, _cond, page):
        try:
            data = os.read(fd, 65536)
        except (OSError, ValueError):
            return True
        if not data:
            return True
        page.fifo_buf += data
        while b"\n" in page.fifo_buf:
            raw, page.fifo_buf = page.fifo_buf.split(b"\n", 1)
            ev = parse_fifo_line(raw.decode("utf-8", "replace").strip())
            if ev:
                self._on_shell_event(page, ev)
        return True

    def _on_shell_event(self, page, ev):
        kind, code, cmd = ev
        if os.environ.get("SPLICE_DEBUG"):
            print("splice[debug]: shell event %s tab=%s" % (
                (kind, code, cmd), page.splice_uuid[:8]), file=sys.stderr)
        if kind == "editor":
            self._arm_editor_watch(page, cmd)
            return
        # the FIFO record races VTE's own pty drain — give the terminal a
        # beat to render the command's output before capturing it
        GLib.timeout_add(150, self._process_exit_event, page, code, cmd)

    def _process_exit_event(self, page, code, cmd):
        if not page.get_parent():
            return False
        term = page.splice_term
        try:
            _col, row = term.get_cursor_position()
        except (AttributeError, TypeError):
            row = 0
        out = self._capture_range(term, page.mark_row, row)
        page.mark_row = row
        page.last_cmd, page.last_exit, page.last_output = cmd, code, out
        self._hide_rescue(page)
        page.rescue_seq += 1
        if self.session_enabled:
            self._schedule_persist()
        if code != 0 and cmd and code not in RESCUE_SKIP_CODES:
            threading.Thread(
                target=self._rescue_worker,
                args=(page, page.rescue_seq, cmd, code, out, page.splice_cwd),
                daemon=True).start()
        return False

    def _capture_range(self, term, r0, r1):
        """Scrollback text between two absolute rows (capped at 400)."""
        if r1 <= r0:
            return ""
        r0 = max(r0, r1 - 400, 0)
        res = None
        try:
            # VTE >= 0.72: non-deprecated, no attribute-array warning
            res = term.get_text_range_format(Vte.Format.TEXT, r0, 0, r1, -1)
        except (AttributeError, TypeError):
            try:
                res = term.get_text_range(r0, 0, r1, -1, None)
            except TypeError:
                try:
                    res = term.get_text_range(r0, 0, r1, -1)
                except (TypeError, AttributeError):
                    return ""
            except AttributeError:
                return ""
        text = res[0] if isinstance(res, tuple) else res
        return (text or "").strip("\n")

    def _capture_tail(self, term, nrows):
        try:
            _col, row = term.get_cursor_position()
        except (AttributeError, TypeError):
            return ""
        return self._capture_range(term, max(0, row - nrows), row)

    # ---------------------------------------------------- F1 rescue strip --
    def _build_rescue_strip(self, page):
        rev = Gtk.Revealer()
        rev.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        rev.set_transition_duration(120)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        outer.get_style_context().add_class("splice-strip")
        outer.get_style_context().add_class("rescue-strip")
        outer.set_border_width(6)
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bolt = Gtk.Label(label="⌁")
        bolt.get_style_context().add_class("strip-bolt")
        row.pack_start(bolt, False, False, 2)
        page.rescue_title = Gtk.Label(label="")
        page.rescue_title.set_xalign(0)
        page.rescue_title.set_ellipsize(Pango.EllipsizeMode.END)
        page.rescue_title.get_style_context().add_class("strip-title")
        row.pack_start(page.rescue_title, True, True, 0)
        chip = Gtk.Label(label="0 tok")
        chip.get_style_context().add_class("chip")
        chip.get_style_context().add_class("local")
        chip.set_tooltip_text("Local deterministic fix — no model call")
        row.pack_start(chip, False, False, 0)
        page.rescue_hint = Gtk.Label(label="")
        page.rescue_hint.get_style_context().add_class("keys-hint")
        page.rescue_hint.set_margin_end(8)
        row.pack_start(page.rescue_hint, False, False, 2)
        outer.pack_start(row, False, False, 0)
        page.rescue_detail = Gtk.Label(label="")
        page.rescue_detail.set_xalign(0)
        page.rescue_detail.set_line_wrap(True)
        page.rescue_detail.set_selectable(True)
        page.rescue_detail.set_no_show_all(True)
        page.rescue_detail.get_style_context().add_class("strip-detail")
        outer.pack_start(page.rescue_detail, False, False, 0)
        rev.add(outer)
        return rev

    def _rescue_worker(self, page, seq, cmd, code, output, cwd):
        errf = os.path.join(run_dir(), page.splice_uuid + ".out")
        have_out = False
        try:
            with open(errf, "w", errors="replace") as f:
                f.write(output or "")
            have_out = True
        except OSError:
            pass
        argv = cli_cmd("splice-ai", "rescue", "--cmd", cmd,
                       "--exit", str(code), "--cwd", cwd or "")
        if have_out:
            argv += ["--stderr-file", errf]
        rc, out, _err = run_cli(argv, timeout=8)
        if os.environ.get("SPLICE_DEBUG"):
            print("splice[debug]: rescue rc=%s out=%r err=%r"
                  % (rc, out[:200], _err[:200]), file=sys.stderr)
        if rc != 0 or not out.strip():
            return
        try:
            d = json.loads(out)
        except ValueError:
            return
        if not isinstance(d, dict) or d.get("source") != "local":
            return
        GLib.idle_add(self._show_rescue, page, seq, d)

    def _show_rescue(self, page, seq, d):
        if seq != page.rescue_seq or not page.get_parent():
            return False  # stale result or tab already closed
        page.rescue_data = d
        title = str(d.get("title") or d.get("suggestion") or "").strip()
        fix = str(d.get("fix") or d.get("suggestion") or "").strip()
        detail = str(d.get("detail") or "").strip()
        if not title:
            title = fix or "local diagnosis"
        hints = []
        if fix:
            hints.append("Tab insert")
        if detail:
            hints.append("Space detail")
        hints.append("A ask")
        hints.append("Esc")
        page.rescue_title.set_text(title)
        page.rescue_title.set_tooltip_text(fix or None)
        page.rescue_hint.set_text(" · ".join(hints))
        page.rescue_detail.set_text(detail)
        page.rescue_detail.hide()
        page.rescue_rev.set_reveal_child(True)
        self.ledger["local_fixes"] = int(self.ledger.get("local_fixes") or 0) + 1
        ledger_save(self.ledger)
        self._update_ledger_chip()
        if page.rescue_timer:
            GLib.source_remove(page.rescue_timer)
        page.rescue_timer = GLib.timeout_add_seconds(
            RESCUE_HIDE_S, self._rescue_timeout, page)
        return False

    def _rescue_timeout(self, page):
        page.rescue_timer = None
        if page.get_parent():
            page.rescue_rev.set_reveal_child(False)
        return False

    def _hide_rescue(self, page):
        if page.rescue_timer:
            GLib.source_remove(page.rescue_timer)
            page.rescue_timer = None
        page.rescue_rev.set_reveal_child(False)

    def _rescue_insert(self, page):
        d = page.rescue_data or {}
        fix = str(d.get("fix") or d.get("suggestion") or "").strip()
        if fix:
            # text only, NO newline — the user's Enter is the only trigger
            self.feed_active(fix)
        self._hide_rescue(page)

    def _rescue_toggle_detail(self, page):
        d = page.rescue_data or {}
        if not str(d.get("detail") or "").strip():
            return
        vis = page.rescue_detail.get_visible()
        page.rescue_detail.set_visible(not vis)
        if page.rescue_timer:  # reading the card: restart the clock
            GLib.source_remove(page.rescue_timer)
            page.rescue_timer = GLib.timeout_add_seconds(
                RESCUE_HIDE_S, self._rescue_timeout, page)

    # ---------------------------------------------------- F6 explain card --
    def _build_explain_card(self, page):
        rev = Gtk.Revealer()
        rev.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        rev.set_transition_duration(120)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        outer.get_style_context().add_class("splice-strip")
        outer.get_style_context().add_class("explain-card")
        outer.set_border_width(6)
        head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bolt = Gtk.Label(label="⌁")
        bolt.get_style_context().add_class("strip-bolt")
        head.pack_start(bolt, False, False, 2)
        page.explain_title = Gtk.Label(label="")
        page.explain_title.set_xalign(0)
        page.explain_title.set_ellipsize(Pango.EllipsizeMode.END)
        page.explain_title.get_style_context().add_class("strip-title")
        head.pack_start(page.explain_title, True, True, 0)
        page.explain_badge = Gtk.Label(label="")
        page.explain_badge.get_style_context().add_class("chip")
        page.explain_badge.get_style_context().add_class("local")
        head.pack_start(page.explain_badge, False, False, 0)
        outer.pack_start(head, False, False, 0)
        page.explain_grid = Gtk.Grid()
        page.explain_grid.set_column_spacing(16)
        page.explain_grid.set_row_spacing(2)
        page.explain_grid.set_margin_start(22)
        outer.pack_start(page.explain_grid, False, False, 0)
        page.explain_ai_label = Gtk.Label(label="")
        page.explain_ai_label.set_xalign(0)
        page.explain_ai_label.set_line_wrap(True)
        page.explain_ai_label.set_selectable(True)
        page.explain_ai_label.set_no_show_all(True)
        page.explain_ai_label.set_margin_start(22)
        page.explain_ai_label.get_style_context().add_class("strip-detail")
        outer.pack_start(page.explain_ai_label, False, False, 0)
        page.explain_footer = Gtk.Label(label="")
        page.explain_footer.set_xalign(0)
        page.explain_footer.set_margin_start(22)
        page.explain_footer.get_style_context().add_class("keys-hint")
        outer.pack_start(page.explain_footer, False, False, 0)
        rev.add(outer)
        return rev

    def explain_current(self):
        """Ctrl+Shift+E. Robustness choice (documented): a selection is the
        precise source; otherwise the cursor row is read and a prompt prefix
        is stripped heuristically. Wrapped multi-row prompt lines are only
        partially captured — select the command for those."""
        page = self.current_page()
        term = getattr(page, "splice_term", None) if page else None
        if not term:
            return
        text = ""
        if term.get_has_selection():
            text = (self._selection_text(term) or "").replace("\n", " ")
        else:
            try:
                _col, row = term.get_cursor_position()
                line = self._capture_range(term, row, row + 1)
            except (AttributeError, TypeError):
                line = ""
            text = strip_prompt_prefix(line)
        text = " ".join(text.split())
        self._hide_rescue(page)
        if not text:
            page.explain_title.set_text(
                "nothing to explain — type a command or select text")
            page.explain_badge.set_text("0 tok")
            self._clear_explain_body(page)
            page.explain_footer.set_text("Esc")
            page.explain_rev.set_reveal_child(True)
            return
        page.explain_cmd = text
        page.explain_title.set_text("explain: " + text)
        page.explain_badge.set_text("…")
        self._clear_explain_body(page)
        page.explain_footer.set_text("")
        page.explain_rev.set_reveal_child(True)
        threading.Thread(target=self._explain_worker, args=(page, text),
                         daemon=True).start()

    def _clear_explain_body(self, page):
        for child in page.explain_grid.get_children():
            page.explain_grid.remove(child)
        page.explain_ai_label.hide()

    def _explain_worker(self, page, text):
        rc, out, _err = run_cli(cli_cmd("splice-ai", "explain", text),
                                timeout=15)
        d = {}
        try:
            d = json.loads(out) if out.strip() else {}
        except ValueError:
            d = {}
        GLib.idle_add(self._explain_done, page, text, d)

    def _explain_done(self, page, text, d):
        if not page.get_parent() or page.explain_cmd != text:
            return False
        items = d.get("tokens") if isinstance(d, dict) else None
        if d.get("source") != "local" or not isinstance(items, list):
            page.explain_badge.set_text("no local parse")
            page.explain_footer.set_text("[A] ask AI · Esc")
            page.explain_rev.set_reveal_child(True)
            return False
        self._clear_explain_body(page)
        srcs = {str(i.get("source") or "") for i in items if isinstance(i, dict)}
        badge = "0 tok · man-parse" if "man" in srcs else (
            "0 tok · curated" if "builtin" in srcs else "0 tok · heuristic")
        page.explain_badge.set_text(badge)
        r = 0
        for item in items:
            if not isinstance(item, dict):
                continue
            tok = Gtk.Label(label=str(item.get("token") or ""))
            tok.set_xalign(0)
            tok.get_style_context().add_class("explain-token")
            mean = Gtk.Label(label=str(item.get("meaning") or ""))
            mean.set_xalign(0)
            mean.set_line_wrap(True)
            mean.get_style_context().add_class("explain-meaning")
            page.explain_grid.attach(tok, 0, r, 1, 1)
            page.explain_grid.attach(mean, 1, r, 1, 1)
            r += 1
        page.explain_grid.show_all()
        page.explain_footer.set_text("[A] deeper with AI · Esc")
        return False

    def _explain_ai(self, page):
        text = page.explain_cmd
        if not text:
            return
        if not sc.claude_available():
            page.explain_badge.set_text("claude CLI not found")
            return
        est = sc.est_tokens(text) + 40
        page.explain_badge.set_text("asking haiku · ~%d tok" % est)
        self.ledger = ledger_add(self.ledger, "explain", est)
        ledger_save(self.ledger)
        self._update_ledger_chip()
        threading.Thread(target=self._explain_ai_worker, args=(page, text),
                         daemon=True).start()

    def _explain_ai_worker(self, page, text):
        rc, out, _err = run_cli(
            cli_cmd("splice-ai", "explain", text, "--ai"), timeout=60)
        ans = ""
        try:
            d = json.loads(out) if out.strip() else {}
            if isinstance(d, dict) and d.get("source") == "ai":
                ans = str(d.get("text") or "")
        except ValueError:
            pass
        GLib.idle_add(self._explain_ai_done, page, text, ans)

    def _explain_ai_done(self, page, text, ans):
        if not page.get_parent() or page.explain_cmd != text:
            return False
        if not ans:
            page.explain_badge.set_text("AI tier failed — local card kept")
            return False
        page.explain_badge.set_text("haiku")
        page.explain_ai_label.set_text(ans)
        page.explain_ai_label.show()
        page.explain_footer.set_text("Esc")
        return False

    # -------------------------------------------------- F8 editor lifeline --
    def _build_editor_overlay(self, page):
        rev = Gtk.Revealer()
        rev.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN)
        rev.set_transition_duration(120)
        rev.set_halign(Gtk.Align.FILL)
        rev.set_valign(Gtk.Align.START)
        eb = Gtk.EventBox()
        box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        box.get_style_context().add_class("editor-overlay")
        box.set_border_width(6)
        bolt = Gtk.Label(label="⌁")
        bolt.get_style_context().add_class("strip-bolt")
        box.pack_start(bolt, False, False, 2)
        page.editor_label = Gtk.Label(label="")
        page.editor_label.set_xalign(0)
        page.editor_label.get_style_context().add_class("strip-title")
        box.pack_start(page.editor_label, True, True, 0)
        page.editor_offer_btn = Gtk.Button(label="prefer nano for git/cron")
        page.editor_offer_btn.get_style_context().add_class("hbtn")
        page.editor_offer_btn.set_no_show_all(True)
        page.editor_offer_btn.connect(
            "clicked", lambda *_: self._prefer_nano(page))
        box.pack_start(page.editor_offer_btn, False, False, 0)
        hint = Gtk.Label(label="click to dismiss")
        hint.get_style_context().add_class("keys-hint")
        box.pack_start(hint, False, False, 2)
        eb.add(box)
        eb.connect("button-press-event", self._on_editor_overlay_click, page)
        rev.add(eb)
        return rev

    def _on_editor_overlay_click(self, _w, _ev, page):
        page.editor_rev.set_reveal_child(False)
        return True

    def _arm_editor_watch(self, page, cmd):
        page.editor_cmd = cmd
        page.editor_deadline = time.time() + 10.0
        if not page.editor_poll:
            page.editor_poll = True
            GLib.timeout_add(500, self._editor_poll, page)

    def _editor_poll(self, page):
        if not page.get_parent():
            page.editor_poll = False
            return False
        if page.editor_pid:
            if not os.path.exists("/proc/%d" % page.editor_pid):
                page.editor_pid = 0
                page.editor_poll = False
                page.editor_rev.set_reveal_child(False)
                return False
            return True
        found = find_editor_proc(page.shell_pid)
        if found:
            page.editor_pid, kind = found
            self._show_editor_overlay(page, kind)
            return True
        if time.time() > page.editor_deadline:
            page.editor_poll = False
            return False
        return True

    def _show_editor_overlay(self, page, kind):
        if kind == "nano":
            page.editor_label.set_text("nano — ^O save · ^X exit")
        else:
            page.editor_label.set_text(
                "vim — Esc then :wq save+quit · :q! abandon · i insert")
        offer = (kind == "vim"
                 and GIT_CRON_RE.match(page.editor_cmd or "")
                 and not os.path.exists(self._editor_offer_marker()))
        page.editor_offer_btn.set_visible(bool(offer))
        page.editor_rev.set_reveal_child(True)
        page.editor_rev.show_all()
        page.editor_offer_btn.set_visible(bool(offer))

    def _editor_offer_marker(self):
        return os.path.join(sc.config_dir(), "editor-offer-done")

    def _prefer_nano(self, page):
        """One-time offer accept: export EDITOR=nano via the SPLICE-managed
        env.sh that splice.bashrc sources. Zero pty injection."""
        env_sh = os.path.join(sc.config_dir(), "env.sh")
        line = "export EDITOR=nano"
        try:
            existing = ""
            if os.path.isfile(env_sh):
                with open(env_sh) as f:
                    existing = f.read()
            if line not in existing:
                with open(env_sh, "a") as f:
                    if existing and not existing.endswith("\n"):
                        f.write("\n")
                    f.write("# set by SPLICE editor-lifeline offer\n"
                            + line + "\n")
            with open(self._editor_offer_marker(), "w") as f:
                f.write(str(time.time()) + "\n")
        except OSError:
            return
        page.editor_offer_btn.hide()
        page.editor_label.set_text(
            "EDITOR=nano saved — takes effect in new tabs")

    # ------------------------------------------------- F7 durable tabs -----
    def _schedule_persist(self):
        if not self.session_enabled or self._persist_pending:
            return
        self._persist_pending = True
        GLib.timeout_add_seconds(5, self._persist_timeout)

    def _persist_timeout(self):
        self._persist_pending = False
        self._persist_all(clean=False)
        return False

    def _persist_all(self, clean):
        if not self.session_enabled:
            return
        tabs = []
        for page in self.notebook.get_children():
            if getattr(page, "splice_is_command", False):
                continue  # -e tabs are never persisted
            try:
                self._persist_tab(page)
            except Exception:  # persistence must never break the terminal
                continue
            tabs.append({"id": page.splice_uuid,
                         "cwd": page.splice_cwd,
                         "title": page.splice_label.get_text()})
        chat = ""
        if self._chat_has_content:
            buf = self.chat_view.get_buffer()
            chat = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False)
        save_session({
            "version": 1,
            "clean": bool(clean),
            "ts": time.time(),
            "tabs": tabs,
            "active": max(0, self.notebook.get_current_page()),
            "font_scale": self.font_scale,
            "chat": chat,
        })

    def _persist_tab(self, page):
        d = os.path.join(tabs_root(), page.splice_uuid)
        os.makedirs(d, exist_ok=True)
        sc.atomic_write_json(os.path.join(d, "meta.json"), {
            "cwd": page.splice_cwd,
            "title": page.splice_label.get_text(),
            "font_scale": self.font_scale,
            "ts": time.time(),
        })
        text = self._term_contents(page.splice_term)
        tmp = os.path.join(d, ".scrollback.tmp")
        with gzip.open(tmp, "wt", encoding="utf-8", errors="replace") as f:
            f.write(tail_lines(text, RESTORE_TAIL_LINES))
        os.replace(tmp, os.path.join(d, "scrollback.gz"))

    def _term_contents(self, term):
        try:
            stream = Gio.MemoryOutputStream.new_resizable()
            term.write_contents_sync(stream, Vte.WriteFlags.DEFAULT, None)
            stream.close(None)
            return stream.steal_as_bytes().get_data().decode(
                "utf-8", "replace")
        except (GLib.Error, AttributeError, TypeError):
            return self._capture_tail(term, RESTORE_TAIL_LINES)

    def _on_delete(self, *_):
        if self.session_enabled:
            try:
                self._persist_all(clean=True)
            except Exception:
                pass
        return False  # continue with destroy

    def _do_restore(self):
        sess = self._pending_restore
        self._pending_restore = None
        self.restore_rev.set_reveal_child(False)
        if not sess:
            return
        try:
            fs = float(sess.get("font_scale") or 0)
            if fs:
                self.font_scale = max(0.4, min(3.0, fs))
        except (TypeError, ValueError):
            pass
        first = self.current_page()
        restored = 0
        for t in sess.get("tabs") or []:
            if not isinstance(t, dict):
                continue
            tid = str(t.get("id") or "")
            text = ""
            if tid and re.fullmatch(r"[0-9a-f]{32}", tid):
                gz = os.path.join(tabs_root(), tid, "scrollback.gz")
                try:
                    with gzip.open(gz, "rt", encoding="utf-8",
                                   errors="replace") as f:
                        text = tail_lines(f.read(), RESTORE_TAIL_LINES)
                except (OSError, EOFError):
                    text = ""
            self.new_tab(cwd=t.get("cwd"), restore_text=text or None,
                         title=t.get("title"))
            restored += 1
        chat = str(sess.get("chat") or "")
        if chat:
            self.chat_view.get_buffer().set_text(chat)
            self._chat_has_content = True
        # drop the pristine pre-restore tab so the session comes back exact
        if os.environ.get("SPLICE_DEBUG"):
            print("splice[debug]: restore done n=%d first_last_cmd=%r"
                  % (restored, getattr(first, "last_cmd", None)),
                  file=sys.stderr)
        if restored and first and not getattr(first, "last_cmd", ""):
            self._close_page(first)
        for page in self.notebook.get_children():
            term = getattr(page, "splice_term", None)
            if term:
                term.set_font_scale(self.font_scale)
        self._schedule_persist()

    def _dismiss_restore(self):
        self._pending_restore = None
        self.restore_rev.set_reveal_child(False)
        t = self.current_term()
        if t:
            t.grab_focus()

    # --------------------------------------------------------------- keys --
    def _install_keys(self):
        self.connect("key-press-event", self._on_key)

    def _on_key(self, _w, event):
        state = event.state & Gtk.accelerator_get_default_mod_mask()
        ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK)
        shift = bool(state & Gdk.ModifierType.SHIFT_MASK)
        kv = Gdk.keyval_to_lower(event.keyval)

        # F7 restore bar owns Enter/Esc while visible
        if self.restore_rev.get_reveal_child():
            if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
                self._do_restore()
                return True
            if event.keyval == Gdk.KEY_Escape:
                self._dismiss_restore()
                return True

        page = self.current_page()
        term = getattr(page, "splice_term", None) if page else None
        term_focused = bool(term and term.has_focus())

        # F1 rescue strip keys (terminal focus only; ask bar keeps its keys)
        if page and term_focused and page.rescue_rev.get_reveal_child():
            if event.keyval in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab):
                self._rescue_insert(page)
                return True
            if event.keyval == Gdk.KEY_space and not ctrl:
                self._rescue_toggle_detail(page)
                return True
            if kv == Gdk.KEY_a and shift and not ctrl:
                self._hide_rescue(page)
                self.ask_about_last(page)
                return True
            if event.keyval == Gdk.KEY_Escape:
                self._hide_rescue(page)
                return True
            if not ctrl and event.keyval < 0xF000:
                # any ordinary keystroke: hide and let the terminal have it
                self._hide_rescue(page)
        # F6 explain card keys
        elif page and term_focused and page.explain_rev.get_reveal_child():
            if kv == Gdk.KEY_a and shift and not ctrl:
                self._explain_ai(page)
                return True
            if event.keyval == Gdk.KEY_Escape:
                page.explain_rev.set_reveal_child(False)
                return True
            if not ctrl and event.keyval < 0xF000:
                page.explain_rev.set_reveal_child(False)

        if event.keyval == Gdk.KEY_Escape and self.ai_revealer.get_reveal_child():
            self.toggle_ai_bar(False)
            return True
        if ctrl and shift:
            if kv == Gdk.KEY_t:
                self.new_tab()
                return True
            if kv == Gdk.KEY_w:
                if page:
                    self._close_page(page)
                return True
            if kv == Gdk.KEY_c:
                self.copy_selection()
                return True
            if kv == Gdk.KEY_v:
                self.paste_clipboard()
                return True
            if kv == Gdk.KEY_a:
                self.ask_about_last()
                return True
            if kv == Gdk.KEY_e:
                self.explain_current()
                return True
        # smart Ctrl+C: with an active selection copy it; otherwise SIGINT as usual
        if ctrl and not shift and kv == Gdk.KEY_c:
            if term and term.get_has_selection():
                self.copy_selection()
                return True
            return False
        if event.keyval == Gdk.KEY_Insert and ctrl and not shift:
            self.copy_selection()
            return True
        if event.keyval == Gdk.KEY_Insert and shift and not ctrl:
            self.paste_clipboard()
            return True
        if ctrl and event.keyval == Gdk.KEY_space:
            self.toggle_ai_bar()
            return True
        if ctrl and not shift and kv == Gdk.KEY_b:
            self.toggle_sidebar()
            return True
        if ctrl and event.keyval in (Gdk.KEY_plus, Gdk.KEY_equal, Gdk.KEY_KP_Add):
            self.font_step(+1)
            return True
        if ctrl and event.keyval in (Gdk.KEY_minus, Gdk.KEY_KP_Subtract):
            self.font_step(-1)
            return True
        return False

    # -------------------------------------------------------- clipboard --
    def copy_selection(self, term=None):
        term = term or self.current_term()
        if not term:
            return
        try:
            term.copy_clipboard_format(Vte.Format.TEXT)
        except AttributeError:
            term.copy_clipboard()

    def paste_clipboard(self, term=None):
        term = term or self.current_term()
        if term:
            term.paste_clipboard()

    def _on_term_button(self, term, event):
        if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3:
            menu = Gtk.Menu()
            menu.get_style_context().add_class("splice-menu")

            mi_copy = Gtk.MenuItem(label="Copy    (Ctrl+Shift+C)")
            mi_copy.set_sensitive(term.get_has_selection())
            mi_copy.connect("activate", lambda *_: self.copy_selection(term))
            menu.append(mi_copy)

            mi_paste = Gtk.MenuItem(label="Paste    (Ctrl+Shift+V)")
            mi_paste.connect("activate", lambda *_: self.paste_clipboard(term))
            menu.append(mi_paste)

            if term.get_has_selection():
                menu.append(Gtk.SeparatorMenuItem())
                mi_ask = Gtk.MenuItem(label="⌁ Ask about selection")
                mi_ask.connect(
                    "activate", lambda *_: self.ask_about_selection(term))
                menu.append(mi_ask)

            menu.append(Gtk.SeparatorMenuItem())

            mi_all = Gtk.MenuItem(label="Select all")
            mi_all.connect("activate", lambda *_: term.select_all())
            menu.append(mi_all)

            menu.show_all()
            menu.attach_to_widget(term, None)
            menu.popup_at_pointer(event)
            return True
        return False

    def font_step(self, direction):
        scale = self.font_scale * (1.1 if direction > 0 else (1 / 1.1))
        self.font_scale = max(0.4, min(3.0, scale))
        for page in self.notebook.get_children():
            term = getattr(page, "splice_term", None)
            if term:
                term.set_font_scale(self.font_scale)


def parse_args(argv):
    """x-terminal-emulator style args: -e CMD [ARGS...], --, --working-directory, -T."""
    cwd, command, title = None, None, None
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("-e", "-x", "--command"):
            rest = argv[i + 1:]
            if not rest:
                sys.stderr.write("splice: %s requires a command\n" % a)
                sys.exit(2)
            # single argument with spaces -> run through a shell (gnome-terminal style)
            if len(rest) == 1 and (" " in rest[0] or "\t" in rest[0]):
                command = ["/bin/bash", "-c", rest[0]]
            else:
                command = rest
            break
        if a == "--":
            command = argv[i + 1:] or None
            break
        if a in ("--working-directory", "-w"):
            i += 1
            if i < len(argv):
                cwd = argv[i]
        elif a.startswith("--working-directory="):
            cwd = a.split("=", 1)[1]
        elif a in ("-T", "--title"):
            i += 1
            if i < len(argv):
                title = argv[i]
        elif a.startswith("--title="):
            title = a.split("=", 1)[1]
        elif a in ("-h", "--help"):
            sys.stdout.write(
                "usage: splice [--working-directory DIR] [-e CMD [ARGS...]] [-- CMD [ARGS...]]\n"
                "       splice test    (headless logic self-test)\n"
            )
            sys.exit(0)
        else:
            sys.stderr.write("splice: ignoring unknown option: %s\n" % a)
        i += 1
    if cwd:
        cwd = os.path.abspath(os.path.expanduser(cwd))
    return cwd, command, title


# ---------------------------------------------------------------------------
# self-test (`python3 splice test`) — headless: exercises the logic helpers
# and the live FIFO plumbing against the real splice.bashrc under a pty.
# No window is opened.
# ---------------------------------------------------------------------------
def _selftest():
    import pty
    import select
    import tempfile

    fails = []

    def check(name, cond, detail=""):
        print(("  [ok]   " if cond else "  [FAIL] ") + name
              + ("" if cond else " — " + " ".join(str(detail).split())[:300]))
        if not cond:
            fails.append(name)

    # 1) FIFO line parsing
    b64 = base64.b64encode(b"git status").decode()
    check("parse exit line", parse_fifo_line("1\t" + b64) == ("exit", 1, "git status"))
    check("parse editor line",
          parse_fifo_line("EDITOR\t" + b64) == ("editor", 0, "git status"))
    check("parse junk", parse_fifo_line("nonsense") is None
          and parse_fifo_line("") is None
          and parse_fifo_line("x\ty\tz") is None)
    check("parse bad b64 keeps code",
          parse_fifo_line("2\t!!!") in (("exit", 2, ""), None))

    # 2) prompt strip heuristic
    check("prompt strip user@host",
          strip_prompt_prefix("hank@box:~/src$ git status") == "git status")
    check("prompt strip root",
          strip_prompt_prefix("root@box:/etc# ls -la") == "ls -la")
    check("prompt strip bare", strip_prompt_prefix("ls -la") == "ls -la")
    check("prompt strip empty", strip_prompt_prefix("") == "")

    # 3) tail_lines
    txt = "\n".join(str(i) for i in range(50))
    check("tail_lines cap", tail_lines(txt, 10) ==
          "\n".join(str(i) for i in range(40, 50)))
    check("tail_lines under", tail_lines("a\nb", 10) == "a\nb")

    # 4) editor process helpers
    check("editor_kind vim", editor_kind("vim.basic") == "vim")
    check("editor_kind nano", editor_kind("nano") == "nano")
    check("editor_kind other", editor_kind("bash") is None)
    check("git/cron offer regex",
          bool(GIT_CRON_RE.match("git commit -a"))
          and bool(GIT_CRON_RE.match("sudo crontab -e"))
          and not GIT_CRON_RE.match("git status"))

    tmp = tempfile.mkdtemp(prefix="splice-gtktest-")
    try:
        # 5) ledger
        lp = os.path.join(tmp, "ledger.json")
        led = ledger_load(lp)
        now = time.time()
        ledger_add(led, "cmd", 120, now=now)
        ledger_add(led, "chat", 300, now=now + 1)
        ledger_save(led, lp)
        led2 = ledger_load(lp)
        check("ledger totals", ledger_today(led2, now=now) == 420)
        rec = ledger_recent(led2, 10)
        check("ledger recent", len(rec) == 2 and rec[0]["est"] == 300)

        # 6) sentry mapping
        s = sentry_spec({"level": "read"})
        check("sentry read", s["run"] and s["chip_class"] == "ok")
        s = sentry_spec({"level": "destructive", "effect": "deletes 3 files",
                         "privileged": True})
        check("sentry destructive", not s["run"]
              and s["type_label"] == "INSERT (GUARDED)"
              and "SUDO" in s["chip"] and s["type_sensitive"])
        s = sentry_spec({"level": "block", "reasons": ["protected path"]})
        check("sentry block", not s["run"] and not s["type_sensitive"])
        s = sentry_spec(None)
        check("sentry unknown degrades open", s["run"] and s["type_sensitive"])
        s = sentry_spec({"class": "mutating"})
        check("sentry legacy class key", s["row_class"] == "risk-med")

        # 7) session offer logic
        home = os.path.expanduser("~")
        check("offer: none", not restore_offer(None))
        check("offer: empty", not restore_offer({"tabs": []}))
        check("offer: clean single default",
              not restore_offer({"clean": True, "tabs": [{"cwd": home}]}))
        check("offer: clean single elsewhere",
              restore_offer({"clean": True, "tabs": [{"cwd": "/tmp"}]}))
        check("offer: crash single default",
              restore_offer({"clean": False, "tabs": [{"cwd": home}]}))
        check("offer: clean multi",
              restore_offer({"clean": True,
                             "tabs": [{"cwd": home}, {"cwd": home}]}))

        # 8) retention prune
        old = os.path.join(tmp, "tabs", "deadbeef" * 4)
        new = os.path.join(tmp, "tabs", "cafebabe" * 4)
        os.makedirs(old)
        os.makedirs(new)
        past = time.time() - 8 * 86400
        os.utime(old, (past, past))
        gone = prune_tab_dirs(root=os.path.join(tmp, "tabs"))
        check("prune old tab dir", not os.path.isdir(old) and "deadbeef" * 4 in gone)
        check("prune keeps fresh", os.path.isdir(new))

        # 9) live FIFO integration against the real splice.bashrc
        fifo = os.path.join(tmp, "exitpipe")
        os.mkfifo(fifo)
        rfd = os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
        home_d = os.path.join(tmp, "home")
        os.makedirs(home_d)
        with open(os.path.join(home_d, ".bashrc"), "w") as f:
            f.write("PS1='@@PS@@ '\n")
        # a pre-existing history file must NOT produce a ghost exit event
        # (bash loads $HISTFILE after the rcfile — HISTCMD jumps)
        with open(os.path.join(home_d, ".bash_history"), "w") as f:
            f.write("ghost-history-cmd\n")
        env = dict(os.environ, SPLICE="1", SPLICE_SHARE=SHARE,
                   SPLICE_EXITPIPE=fifo, SPLICE_NO_AI="1", HOME=home_d,
                   TERM="xterm-256color", SPLICE_GUARD_TIMEOUT="1")
        env.pop("PROMPT_COMMAND", None)

        pid, master = pty.fork()
        if pid == 0:
            try:
                os.execve("/bin/bash",
                          ["bash", "--rcfile",
                           os.path.join(SHARE, "splice.bashrc"), "-i"], env)
            finally:
                os._exit(127)

        buf = {"pty": b"", "fifo": b""}

        def pump(timeout_s):
            deadline = time.time() + timeout_s
            while time.time() < deadline:
                rd, _, _ = select.select([master], [], [], 0.1)
                if rd:
                    try:
                        d = os.read(master, 65536)
                        if d:
                            buf["pty"] += d
                    except OSError:
                        pass
                try:
                    d = os.read(rfd, 65536)
                    if d:
                        buf["fifo"] += d
                except (BlockingIOError, OSError):
                    pass

        def fifo_events():
            out = []
            for raw in buf["fifo"].decode("utf-8", "replace").splitlines():
                ev = parse_fifo_line(raw.strip())
                if ev:
                    out.append(ev)
            return out

        def wait_event(pred, timeout_s=10):
            deadline = time.time() + timeout_s
            while time.time() < deadline:
                for ev in fifo_events():
                    if pred(ev):
                        return ev
                pump(0.25)
            return None

        try:
            pump(2.0)
            check("pty: prompt renders with exitpipe armed",
                  b"@@PS@@" in buf["pty"], buf["pty"][-300:])
            check("fifo: no ghost event at shell startup",
                  not fifo_events(), buf["fifo"][:200])
            os.write(master, b"false\n")
            ev = wait_event(lambda e: e == ("exit", 1, "false"))
            check("fifo: exit code + cmd for `false`", ev is not None,
                  buf["fifo"][:200])
            os.write(master, b"echo fifo-ok\n")
            ev = wait_event(lambda e: e[0] == "exit" and e[1] == 0
                            and "fifo-ok" in e[2])
            check("fifo: zero exit reported", ev is not None, buf["fifo"][:300])
            # editor pre-exec event (shell function so nothing really runs)
            os.write(master, b"vim() { :; }\n")
            pump(0.5)
            os.write(master, b"vim notes.txt\n")
            ev = wait_event(lambda e: e[0] == "editor"
                            and e[2].startswith("vim"))
            check("fifo: EDITOR pre-exec event", ev is not None,
                  buf["fifo"][:300])
            # OSC 777 fallback still emitted on the pty
            check("pty: OSC777 fallback intact",
                  b"\x1b]777;splice-exit;" in buf["pty"], buf["pty"][-200:])
            os.write(master, b"exit\n")
            pump(1.0)
        finally:
            try:
                os.close(master)
            except OSError:
                pass
            try:
                os.close(rfd)
            except OSError:
                pass
            try:
                os.waitpid(pid, os.WNOHANG)
            except (ChildProcessError, OSError):
                pass
    finally:
        shutil.rmtree(tmp, ignore_errors=True)

    if fails:
        print("SELF-TEST: FAIL — " + ", ".join(fails))
        return 1
    print("SELF-TEST: PASS")
    return 0


def main():
    if sys.argv[1:2] == ["test"]:
        sys.exit(_selftest())
    GLib.set_prgname("splice")
    GLib.set_application_name("SPLICE")
    cwd, command, title = parse_args(sys.argv[1:])
    win = SpliceWindow(cwd=cwd, command=command)
    if title:
        win.set_title(title)
    Gtk.main()


if __name__ == "__main__":
    main()
