#!/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.
"""
import json
import os
import subprocess
import sys
import threading

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, 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


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):
    """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
        )
        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)


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._load_css()
        self._set_icon()
        self._build_header()
        self._build_body()
        self._install_keys()

        self.connect("destroy", Gtk.main_quit)
        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.ai_result_box.hide()
        self.chat_scroll.hide()
        self.ai_status.hide()
        self.spinner.hide()

        # 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)

        self.token_chip = Gtk.Label(label="~0 tok")
        self.token_chip.get_style_context().add_class("chip")
        self.token_chip.set_tooltip_text("Total tokens of enabled contexts")
        hb.pack_end(self._build_menu())
        hb.pack_end(self.token_chip)

        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)

    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.2.1")
        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)

        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.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)
        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)

        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
        self.ai_result_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        self.ai_result_box.get_style_context().add_class("result-row")
        pr = Gtk.Label(label="$")
        pr.get_style_context().add_class("result-prompt")
        self.ai_result_box.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")
        self.ai_result_box.pack_start(self.result_label, True, True, 0)
        btn_run = Gtk.Button(label="RUN")
        btn_run.get_style_context().add_class("accent")
        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 (btn_run, self.btn_type, btn_dis):
            self.ai_result_box.pack_start(b, 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)
        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)
        if show:
            self.ai_entry.grab_focus()
        else:
            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"):
            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 _on_ai_submit(self, entry):
        q = entry.get_text().strip()
        if not q:
            return
        if not sc.claude_available():
            self._set_status(
                "claude CLI not found — install/login first "
                "(terminal + local typo rescue still work)",
                "err",
            )
            return
        mode = "cmd" if self.mode_cmd.get_active() else "chat"
        self._set_status("")
        self.spinner.show()
        self.spinner.start()
        entry.set_sensitive(False)
        threading.Thread(target=self._ai_worker, args=(mode, q), daemon=True).start()

    def _ai_worker(self, mode, q):
        sub = "ask" if mode == "cmd" else "chat"
        rc, out, err = run_cli(cli_cmd("splice-ai", sub, q), timeout=120)
        GLib.idle_add(self._ai_done, mode, q, rc, out.strip(), err.strip())

    def _ai_done(self, mode, q, rc, out, err):
        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.btn_type.grab_focus()  # Enter defaults to TYPE
        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()
        return False

    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):
        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)
        )
        self.token_chip.set_text("%s tok" % fmt_tokens(total))
        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("dim")
        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)

    # --------------------------------------------------------------- tabs --
    def new_tab(self, cwd=None, command=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.HORIZONTAL)
        page.pack_start(term, True, True, 0)
        sb = Gtk.Scrollbar(
            orientation=Gtk.Orientation.VERTICAL, adjustment=term.get_vadjustment()
        )
        page.pack_start(sb, False, False, 0)
        page.splice_term = term
        page.splice_cwd = cwd or self.active_cwd()

        label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        lbl = Gtk.Label(label=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()
        self.notebook.set_current_page(idx)

        self._spawn_shell(term, page.splice_cwd, command)
        term.grab_focus()
        return page

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

    def _spawn_shell(self, term, cwd, command=None):
        if command:
            argv = list(command)
        else:
            argv = ["/bin/bash", "--rcfile", os.path.join(SHARE, "splice.bashrc"), "-i"]
        envv = self._child_env()
        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())

    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):
        idx = self.notebook.page_num(page)
        if idx >= 0:
            self.notebook.remove_page(idx)
            page.destroy()
        if self.notebook.get_n_pages() == 0:
            Gtk.main_quit()
        elif not from_exit:
            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()

    # --------------------------------------------------------------- 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)

        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:
                page = self.current_page()
                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
        # smart Ctrl+C: with an active selection copy it; otherwise SIGINT as usual
        if ctrl and not shift and kv == Gdk.KEY_c:
            term = self.current_term()
            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)

            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"
            )
            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


def main():
    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()
