"""app.py — o Primo como app (Finder, Launchpad, Dock): abre sem terminal e funciona inteiro.

O processo do app é o painel (hud.py). Daqui sobem os filhos, todos mortos junto com o app:
  · o motor de voz (`primo_voz`: microfone, Whisper, voz) — o painel dele é este processo (PRIMOCODE_HUD_PONTE);
  · o cérebro: cada frase vira `primocode -p "<frase>" --output-format json` na pasta de trabalho, e o
    resultado volta falado. Node e primocode vêm de ~/.primocode/primo-app.json (o app não tem o PATH do terminal).
Log: ~/.primocode/logs/primo-app.log
"""
from __future__ import annotations

import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import unicodedata
from pathlib import Path

HOME = Path.home()
PASTA_PC = HOME / ".primocode"
LOG = PASTA_PC / "logs" / "primo-app.log"
AQUI = Path(__file__).resolve().parent
PATH_BASE = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"


def log(msg: str) -> None:
    try:
        LOG.parent.mkdir(parents=True, exist_ok=True)
        with open(LOG, "a", encoding="utf-8") as f:
            f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + str(msg).rstrip() + "\n")
    except Exception:
        pass


def _json(caminho: Path) -> dict:
    try:
        return json.loads(caminho.read_text(encoding="utf-8"))
    except Exception:
        return {}


def logado() -> bool:
    c = _json(PASTA_PC / "conta.json")
    return bool(c.get("refreshToken") or c.get("idToken") or c.get("token"))


def _sem_acento(t: str) -> str:
    return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")


RAIZ = HOME / "Primo"          # a área do app: projetos novos por voz nascem em ~/Primo/<nome>


def pasta_atual() -> Path:
    RAIZ.mkdir(exist_ok=True)
    return RAIZ


def gravar_pasta(d: Path) -> None:
    """Mesmo arquivo que o lib/config/config.js (lembrarProjeto) lê: a próxima sessão do PrimoCode cai aqui também."""
    j = _json(PASTA_PC / "projeto-ativo.json")
    rec = [str(d)] + [x for x in (j.get("recentes") or []) if x != str(d)][:9]
    (PASTA_PC / "projeto-ativo.json").write_text(
        json.dumps({"dir": str(d), "origem": "/dir", "quando": int(time.time() * 1000), "recentes": rec}, indent=2), encoding="utf-8")


def achar_projeto(nome: str) -> "Path | None":
    palavras = _sem_acento(nome).replace("-", " ").split()
    if not palavras:
        return None
    cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
    for base in (RAIZ, HOME / "primocode", HOME):
        try:
            cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
        except OSError:
            pass
    for c in cand:
        n = _sem_acento(c.name).replace("-", " ").replace("_", " ")
        if c.is_dir() and all(p in n for p in palavras):
            return c
    return None


PEDIDO_PASTA = re.compile(r"^(?:(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+|trabalha(?:r)?\s+(?:no|na|em)\s+(?:o\s+|a\s+)?(?:projeto\s+|pasta\s+)?)(.+?)[.!?]*$", re.I)
VERBO_ACAO = re.compile(r"^(?:e\s+|ent[aã]o\s+|agora\s+)?(?:cria|crie|criar|faz|fa[cç]a|fazer|monta|monte|gera|gere|escreve|escreva|edita|edite|muda|mude|corrige|corrija|conserta|conserte|instala|instale|roda|rode|executa|execute|abre|abra|apaga|apague|deleta|enxuga|enxugue|atualiza|atualize|adiciona|adicione|remove|remova|refatora|publica|publique|coloca|ponha|p[oõ]e|troca|continua|continue|pode ser|sim|isso|ok|beleza|manda|bora|vai)\b", re.I)
# O 1º motion sem gosto salvo: o motor (em -p, PRIMOCODE_APP=1) escreve no stderr `primocode:abrir-gosto <url>` e espera.
# O app bloqueia navegador por padrão (PRIMOCODE_SEM_NAVEGADOR=1); ESTA é a exceção explícita: a tela local do gosto abre
# no navegador padrão. Só a tela local (127.0.0.1 + token hex) passa; qualquer outra coisa na linha é recusada.
GOSTO_LINHA = re.compile(r"^primocode:abrir-gosto (http://127\.0\.0\.1:\d{2,5}/\?t=[0-9a-f]{32})$")


def url_gosto(linha: str) -> "str | None":
    m = GOSTO_LINHA.match(str(linha or "").strip())
    return m.group(1) if m else None


def abrir_gosto(linha: str, abrir=None) -> bool:
    """True se a linha era do gosto (aberta ou recusada); False se é outra linha qualquer."""
    if not str(linha or "").startswith("primocode:abrir-gosto"):
        return False
    url = url_gosto(linha)
    if not url:
        log("gosto: linha recusada")
        return True
    log("gosto: abrindo a tela do gosto no navegador padrão")
    try:
        (abrir or (lambda u: subprocess.Popen(["open", u], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)))(url)
    except Exception as e:
        log(f"gosto: não abriu: {e}")
    return True


PEDE_NAVEGADOR = re.compile(r"\babr(?:e|a|ir)\b|\bmostr(?:a|e|ar)\b|navegador|visualiz|preview|pr[eé]-?visualiza", re.I)


def _hora_falada(t: "time.struct_time") -> str:
    from primo_voz.fala import hora_falada
    return hora_falada(t)


_OBJETOS = {"site": "o site", "pagina": "a página", "landing": "a landing page", "app": "o app", "aplicativo": "o app", "video": "o vídeo",
            "arquivo": "o arquivo", "pasta": "a pasta", "projeto": "o projeto", "script": "o script", "teste": "os testes", "testes": "os testes",
            "bug": "o bug", "erro": "o erro", "commit": "o commit", "deploy": "o deploy", "codigo": "o código", "documento": "o documento",
            "relatorio": "o relatório", "planilha": "a planilha", "logo": "a logo", "imagem": "a imagem", "motion": "o motion",
            "servidor": "o servidor", "login": "o login", "cardapio": "o cardápio", "botao": "o botão", "layout": "o layout"}
_ACKS = [  # (verbos, variações; {o} = "o site", {de} = "do site", {em} = "no site")
    (r"cri|mont|ger|faz|fa[cç]|escrev|constru|desenh", ["Bora, vou criar {o}.", "Beleza, já começo {o}.", "Fechado, vou montar {o}.", "Pode deixar, vou fazer {o}.", "Show, vou criar {o} agora."]),
    (r"abr|mostr|exib", ["Deixa eu abrir {o}.", "Já abro {o}.", "Vou abrir {o} pra você.", "Um segundo, abrindo {o}."]),
    (r"rod|execut|test|ver(?:ific)", ["Vou rodar isso agora.", "Rodando, já te falo.", "Deixa eu rodar {o}.", "Já executo e te conto."]),
    (r"corrig|conserta|arrum|resolv|debug", ["Vou dar uma olhada {em} e consertar.", "Já vou atrás desse problema.", "Deixa eu ver o que aconteceu {em}."]),
    (r"edit|mud|troc|atualiz|ajust|coloc|pon|adicion|remov|apag", ["Vou ajustar {o} agora.", "Beleza, já mexo {em}.", "Pode deixar, já faço essa mudança.", "Já altero isso."]),
    (r"instal|public|sub[ae]|deploy|mand", ["Vou cuidar disso agora.", "Já começo por aqui.", "Deixa comigo, já faço."]),
    (r"procur|busc|pesquis|analis|olh|le[ia]|explor", ["Vou dar uma olhada {em}.", "Deixa eu procurar isso.", "Já vou investigar."]),
]
_ACK_PADRAO = ["Já cuido disso.", "Deixa comigo.", "Pode deixar, já começo.", "Beleza, vou resolver isso."]


def ack_contextual(texto: str, ultimo: str = "", sorteio=None) -> str:
    """O "já vou" de uma TAREFA demorada, escrito a partir do pedido (verbo e objeto) e nunca igual ao anterior."""
    import random
    sorte = sorteio or random.choice
    t = _sem_acento(texto)
    obj = next((_OBJETOS[w] for w in re.findall(r"[a-z]+", t) if w in _OBJETOS), "")
    grupo = _ACK_PADRAO
    primeira = re.findall(r"[a-z]+", t)[:4]
    for rx, frases in _ACKS:
        if any(re.match(rx, w) for w in primeira):
            grupo = frases
            break
    pool = [f for f in grupo if f != ultimo and ("{" not in f or obj or True)] or grupo
    f = sorte(pool)
    if "{" in f and not obj:
        f = sorte([x for x in grupo if "{" not in x and x != ultimo] or _ACK_PADRAO)
    return f.format(o=obj, de=obj.replace("o ", "do ", 1).replace("a ", "da ", 1).replace("os ", "dos ", 1) if obj else "", em=("n" + obj) if obj else "disso")


class App:
    def __init__(self) -> None:
        self.cfg = _json(PASTA_PC / "primo-app.json")
        self.node = self.cfg.get("node") or ""
        self.primocode = self.cfg.get("primocode") or ""
        self.pasta = pasta_atual()
        self.pasta_modo = "raiz"          # raiz (~/Primo, projetos novos) | projeto (o do último pedido) | /dir (escolhida por voz)
        self.acao_antes = False
        self.ultimo_ack = ""
        self.motor: "subprocess.Popen | None" = None
        self.cerebro: "subprocess.Popen | None" = None
        self.entrada: "queue.Queue" = queue.Queue()
        self.js = lambda m: None
        self.turnos = 0
        self.vivo = True
        self.trava = threading.Lock()
        self.cache_clima: dict = {}
        self.ao_ouvir = lambda: None
        self.desligando = False          # a voz foi fechada de propósito: o motor sair não derruba o app

    # ── entrada do painel (iterável de linhas JSON) ──
    def __iter__(self):
        while True:
            yield self.entrada.get()

    def rotulo(self) -> str:
        return "~/Primo" if self.pasta == RAIZ else self.pasta.name

    def painel(self, msg: dict) -> None:
        self.entrada.put(json.dumps(msg, ensure_ascii=False))

    def saida_do_painel(self, obj: dict) -> None:
        a = obj.get("acao")
        if a == "ptt":
            self.motor_envia({"tipo": "acao", "acao": "ptt"})
        elif a == "entrar":
            self.entrar()

    # ── login ──
    def entrar(self) -> None:
        raiz = Path(self.primocode).resolve().parent.parent if self.primocode else None
        if not (self.node and raiz and (raiz / "lib" / "conta.js").exists()):
            log("entrar: node/primocode não registrados")
            self.painel({"resposta": "Não achei o PrimoCode instalado. Rode `primocode voz instalar-app` no terminal."})
            return

        def trabalho():
            log("login: abrindo o navegador")
            r = subprocess.run([self.node, "-e", "require('./lib/config/conta.js').entrarPeloNavegador({aviso:()=>{}}).then(r=>process.exit(r&&r.ok?0:1))"],
                               cwd=str(raiz), env={**self.ambiente(), "PRIMOCODE_SEM_NAVEGADOR": "0"}, capture_output=True, timeout=600)
            log(f"login: saiu com {r.returncode}")
            self.painel({"login": logado()})
        threading.Thread(target=trabalho, daemon=True).start()

    def entrar_e_esperar(self, limite: float = 600) -> None:
        """O chat espera o login terminar para mostrar a conta e as conversas."""
        self.entrar()
        fim = time.time() + limite
        while time.time() < fim and not logado():
            time.sleep(1)

    def ambiente(self) -> dict:
        env = dict(os.environ)
        extra = [str(Path(self.node).parent)] if self.node else []
        env["PATH"] = ":".join([x for x in extra + [self.cfg.get("path") or "", PATH_BASE, env.get("PATH", "")] if x])
        env["HOME"] = str(HOME)
        env["PRIMOCODE_SEM_NAVEGADOR"] = "1"          # o cérebro nunca abre o navegador sozinho
        return env

    # ── motor de voz ──
    def motor_envia(self, obj: dict) -> None:
        try:
            self.motor.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
            self.motor.stdin.flush()
        except Exception:
            pass

    def subir_motor(self) -> None:
        env = self.ambiente()
        cfg_srv = self.cfg.get("servidor") or ""
        conta = _json(PASTA_PC / "conta.json")
        env.update({"PRIMOCODE_HUD_PONTE": "1", "PRIMOCODE_SERVER": cfg_srv or "https://primocode.web.app",
                    "PRIMOCODE_TOKEN": str(conta.get("idToken") or conta.get("token") or ""),
                    "PRIMOCODE_VOZ": str(self.cfg.get("voz") or "antonio"), "PRIMOCODE_CWD": str(self.pasta),
                    "PYTHONUNBUFFERED": "1", "PRIMOCODE_LAT": "1", "PRIMOCODE_SEM_PARCIAL": "1", **({"PRIMOCODE_TESTE_WAV": str(self.cfg["teste_wav"]), "PRIMOCODE_TESTE_ATRASO": str(self.cfg.get("teste_atraso", 12))} if self.cfg.get("teste_wav") else {}), "PYTHONPATH": os.environ.get("PYTHONPATH", str(AQUI.parent))})
        log(f"motor de voz: subindo ({sys.executable} -m primo_voz) pasta={self.pasta}")
        self.motor = subprocess.Popen([sys.executable, "-m", "primo_voz"], cwd=str(AQUI.parent), env=env, text=True,
                                      stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        threading.Thread(target=self.ler_motor, daemon=True).start()
        threading.Thread(target=lambda: [log("motor: " + l) for l in self.motor.stderr], daemon=True).start()

    def ler_motor(self) -> None:
        for linha in self.motor.stdout:
            try:
                m = json.loads(linha)
            except ValueError:
                continue
            t = m.get("tipo")
            if t == "hud":
                self.painel(m.get("msg") or {})
            elif t == "pronto":
                log("motor de voz: PRONTO (microfone aberto)")
                self.painel({"pasta": "~/Primo", "login": logado()})
            elif t == "erro":
                log("motor de voz: erro: " + str(m.get("texto")))
                self.painel({"resposta": str(m.get("texto"))})
            elif t == "parar":
                self.matar_cerebro()
            elif t == "ouvi" and str(m.get("texto") or "").strip():
                self.ao_ouvir()                     # pedido falado: a tela de voz vem pra frente
                threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
            elif t == "fim":
                break
        log("motor de voz: saiu")
        if self.desligando:
            self.desligando = False
            return
        if self.vivo:
            self.vivo = False
            os._exit(0)

    # ── cérebro ──
    def matar_cerebro(self) -> None:
        p = self.cerebro
        if p and p.poll() is None:
            p.terminate()

    def dizer(self, texto: str) -> None:
        self.motor_envia({"tipo": "falar", "texto": texto})
        self.motor_envia({"tipo": "estado", "estado": "esperando"})

    def responder_local(self, texto: str) -> "str | None":
        """Pergunta simples respondida aqui, sem passar pelo agente (hora, data, clima, música, agenda, volume)."""
        from primo_voz import painel as pn, tela as td
        t = _sem_acento(texto).strip(" .?!")
        agora = time.time()
        if re.search(r"\bque horas\b|\bhoras sao\b|\bme (?:da|diz|diga) a hora\b|\bque hora (?:e|eh)\b", t):
            return _hora_falada(time.localtime(agora))
        if re.search(r"\bque dia (?:e|eh)\b|\bdata de hoje\b|\bhoje e que dia\b", t):
            dias = ["segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo"]
            meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
            lt = time.localtime(agora)
            return f"Hoje é {dias[lt.tm_wday]}, {lt.tm_mday} de {meses[lt.tm_mon - 1]}."
        if re.search(r"\bclima\b|\bvai chover\b|\btemperatura\b|\btempo (?:hoje|agora|la fora)\b", t):
            c = td.clima_widget(self.cache_clima)
            if c and c.get("temp") is not None:
                return f"Em {c.get('cidade', 'sua cidade')} faz {c['temp']} graus, {str(c.get('cond', '')).lower()}. Máxima de {c['max']}, mínima de {c['min']}."
            return None
        if re.search(r"o que (?:esta|ta) tocando|que musica (?:e|eh) essa|qual (?:e )?a musica", t):
            m = td.musica()
            return f"Tocando {m['faixa']}, de {m['artista']}." if m and m.get("faixa") else "Não tem nada tocando agora."
        if re.search(r"\b(?:pausa|pausar|pare|para) (?:a )?musica\b|\b(?:toca|tocar|continua|retoma) (?:a )?musica\b|\bpula (?:a )?musica\b|\bproxima musica\b|\bmusica anterior\b", t):
            acao = "proxima" if re.search(r"pula|proxima", t) else "anterior" if "anterior" in t else "tocar"
            pn.controle_midia(acao)
            return {"proxima": "Pulei.", "anterior": "Voltei.", "tocar": "Feito."}[acao]
        if re.search(r"\b(?:aumenta|sobe|aumentar|mais)\b.*\bvolume\b|\bvolume\b.*\b(?:mais alto|maior)\b", t):
            pn.executar_acao({"acao": "volume", "arg": "mais"})
            return "Aumentei."
        if re.search(r"\b(?:abaixa|diminui|baixa|diminuir|menos)\b.*\bvolume\b|\bvolume\b.*\b(?:mais baixo|menor)\b", t):
            pn.executar_acao({"acao": "volume", "arg": "menos"})
            return "Abaixei."
        if re.search(r"\bagenda\b|\bcompromissos?\b|o que (?:eu )?tenho hoje|\breunioes? hoje\b", t):
            ev = td.eventos_do_mes()
            if ev.get("permissao") != "ok":
                return "Preciso do acesso ao Calendário. Clique em Liberar o Calendário no painel."
            hj = td.hoje_lista(ev["eventos"], agora)
            if not hj:
                return "Você não tem mais nada marcado para hoje."
            return "Hoje: " + "; ".join(f"{e['titulo']} às {e['hora']}" if e["hora"] != "dia todo" else f"{e['titulo']}, o dia todo" for e in hj[:4]) + "."
        return None

    def pensar(self, texto: str) -> None:
        log(f"ouvi: {texto}")
        m = PEDIDO_PASTA.match(texto.strip())
        if m:
            d = achar_projeto(m.group(1))
            if d:
                self.pasta, self.pasta_modo = d, "/dir"
                gravar_pasta(d)
                self.painel({"pasta": d.name})
                log(f"pasta: {d}")
                self.dizer(f"Fechado. Agora trabalho no {d.name}.")
            else:
                self.dizer(f"Não achei um projeto chamado {m.group(1)}.")
            return
        t_ini = time.time()
        try:
            local = self.responder_local(texto)
        except Exception as e:
            log(f"resposta local falhou: {e}")
            local = None
        if local:
            log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
            self.dizer(local)
            return
        if not (self.node and self.primocode and Path(self.primocode).exists()):
            log("cérebro: node/primocode não registrados em primo-app.json")
            self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
            return
        if not logado():
            self.painel({"login": False})
            self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
            return
        acao = bool(VERBO_ACAO.match(_sem_acento(texto).strip())) or bool(re.search(r"\b(?:site|pagina|landing|projeto|arquivo|pasta|codigo|bug|teste|commit|deploy|app|aplicativo|script)s?\b", _sem_acento(texto))) or (self.acao_antes and not re.match(r"^(?:o que|qual|quem|como|por ?que|quando|onde|quanto)\b", _sem_acento(texto).strip()))
        self.acao_antes = acao
        pedido = texto
        pedido += ('\n\n(Modo voz. Termine SEMPRE com uma única linha de JSON: {"falar": "...", "painel": "..."}. '
                   '"falar" é o que será DITO em voz alta: português natural de conversa, 1 a 4 frases curtas, sem markdown, sem código, '
                   'sem caminhos de arquivo, sem siglas nem abreviações; números e unidades por extenso como se fala '
                   '("trinta e dois segundos", "dez pixels", "noventa e nove reais", "versão dez ponto quinze"); nomes técnicos pronunciáveis; '
                   'diga o que você fez de fato. "painel" é a resposta escrita completa, com markdown e detalhes.)')
        if acao:
            pedido += ("\n\n(Pedido por VOZ. Execute de verdade com as ferramentas, sem pedir confirmação, a menos que seja apagar, publicar ou enviar algo. "
                       "Projetos novos ficam em ~/Primo/<nome curto>. Ao terminar, responda em português falado, 2 a 4 frases, dizendo o que fez DE FATO: "
                       "pastas e arquivos criados, onde ficaram e o que abriu. Não use listas nem código na resposta.)")
        with self.trava:
            env = self.ambiente()
            env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
                        "PRIMOCODE_SEM_NAVEGADOR": "0" if (PEDE_NAVEGADOR.search(texto) or (acao and re.search(r"site|p[aá]gina|landing|html", texto, re.I))) else "1"})
            if acao:
                env["PRIMOCODE_MODO"] = "primocode"
            if self.pasta != RAIZ:
                env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.pasta), ("/dir" if self.pasta_modo == "/dir" else "projeto")
            args = [self.node, self.primocode, "-p", pedido, "--output-format", "json"] + (["-c"] if self.turnos else [])
            log(f"cérebro: modo={'AÇÃO' if acao else 'conversa'} cwd={self.pasta} pasta_modo={self.pasta_modo} navegador={'sim' if env['PRIMOCODE_SEM_NAVEGADOR'] == '0' else 'não'} continua={bool(self.turnos)}")
            t0 = time.time()

            def ack_tardio():                       # tarefa: se o agente passou de ~1,2 s, diz o que vai fazer (conversa e pergunta rápida não ganham ack)
                if acao and self.cerebro and self.cerebro.poll() is None:
                    a = ack_contextual(texto, self.ultimo_ack)
                    self.ultimo_ack = a
                    log(f"ack: {a!r}")
                    self.motor_envia({"tipo": "falar", "texto": a})
            timer = threading.Timer(1.2, ack_tardio)
            timer.start()
            try:
                self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=env, text=True,
                                                stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                                start_new_session=True)          # grupo próprio: nada do que o agente faça com processos atinge o app
                linhas_err: "list[str]" = []
                cer = self.cerebro

                def ler_err():                      # ao vivo: a linha do gosto tem que abrir a tela ENQUANTO o motor espera
                    for l in cer.stderr:
                        if not abrir_gosto(l):
                            linhas_err.append(l)
                th_err = threading.Thread(target=ler_err, daemon=True)
                th_err.start()
                out = cer.stdout.read()
                cer.wait(timeout=900)
                th_err.join(timeout=2)
                err = "".join(linhas_err)
                log(f"cérebro: saiu com código {self.cerebro.returncode}")
                timer.cancel()
            except Exception as e:
                timer.cancel()
                log(f"cérebro: falhou: {e}")
                self.dizer("O cérebro não respondeu.")
                return
        for l in (err or "").splitlines():
            if l.startswith("ferramenta:"):
                log(l[:300])
        pj = _json(PASTA_PC / "projeto-ativo.json")          # o agente criou/trocou de projeto? o painel e o próximo pedido seguem nele
        if pj.get("dir") and pj.get("quando", 0) / 1000 >= t0 and Path(pj["dir"]).is_dir() and Path(pj["dir"]) not in (self.pasta, RAIZ):
            self.pasta, self.pasta_modo = Path(pj["dir"]), "projeto"
            self.painel({"pasta": self.rotulo()})
            log(f"pasta do projeto agora: {self.pasta}")
        self.turnos += 1
        resp = ""
        for l in reversed((out or "").strip().splitlines()):
            try:
                j = json.loads(l)
                resp = str(j.get("result") or "")
                break
            except ValueError:
                continue
        sobra = "\n".join(l for l in (err or "").splitlines() if not l.startswith("ferramenta:")).strip()
        if sobra:
            log("cérebro stderr: " + sobra[-400:])
        # tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
        resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿") and not re.match(r"^\s*(?:Projeto novo:|[A-Z][A-Za-z_]{2,12}\s{2,}\S)", l)).replace("●", "").strip()
        log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
        if not resp.strip():
            self.dizer("Não consegui uma resposta agora.")
            return
        falar, painel = self.separar(resp)
        self.motor_envia({"tipo": "falar", "texto": falar, "painel": painel})
        self.motor_envia({"tipo": "estado", "estado": "esperando"})

    @staticmethod
    def separar(resp: str) -> "tuple[str, str]":
        """O cérebro devolve {"falar","painel"} (o terminal quebra a linha no meio do JSON: junta e lê). Sem JSON, vale o texto todo."""
        corpo = re.sub(r"\s*\n\s*", " ", resp)
        i, j = corpo.find('{"falar"'), corpo.rfind("}")
        if i >= 0 and j > i:
            try:
                d = json.loads(corpo[i:j + 1], strict=False)
                if str(d.get("falar") or "").strip():
                    antes = resp[: resp.find('{"falar"')].strip() if '{"falar"' in resp else ""
                    return str(d["falar"]).strip(), (str(d.get("painel") or "").strip() or antes or str(d["falar"]).strip())
            except ValueError:
                pass
            m = re.search(r'"falar"\s*:\s*"(.*?)"\s*(?:,\s*"painel"|\})', corpo, re.S)
            if m:
                return m.group(1).strip(), corpo[:i].strip() or m.group(1).strip()
        return resp, resp

    def sair(self) -> None:
        self.vivo = False
        self.matar_cerebro()
        if self.motor and self.motor.poll() is None:
            self.motor_envia({"tipo": "fim"})
            try:
                self.motor.wait(timeout=2)
            except Exception:
                self.motor.kill()
        log("app: encerrado")

    # ── a voz só liga pelo botão: com o chat na frente, o microfone fica fechado ──
    def ligar_voz(self) -> None:
        with self.trava:
            if self.motor and self.motor.poll() is None:
                return
            self.desligando = False
            self.subir_motor()

    def desligar_voz(self) -> None:
        with self.trava:
            if not self.motor or self.motor.poll() is not None:
                return
            self.desligando = True
            m, self.motor = self.motor, None
        log("motor de voz: desligando (voltou pro chat)")
        try:
            m.stdin.write(json.dumps({"tipo": "fim"}) + "\n")
            m.stdin.flush()
            m.wait(timeout=2)
        except Exception:
            m.kill()

    def iniciar(self, js, p, com_chat: bool = True) -> None:
        self.js = js
        if not com_chat:                        # só o modo voz puro (sem chat) abre ouvindo
            threading.Timer(0.5, self.subir_motor).start()
        threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()


# ── o que aparece embaixo de uma mensagem: os arquivos citados, mostrados ali mesmo ──
TIPOS = {"video": {".mp4", ".mov", ".webm", ".m4v"}, "imagem": {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"},
         "audio": {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"}, "pdf": {".pdf"}, "html": {".html", ".htm"},
         "codigo": {".js", ".jsx", ".ts", ".tsx", ".py", ".css", ".json", ".md", ".txt", ".sh", ".yml", ".yaml", ".toml",
                    ".rs", ".go", ".java", ".kt", ".swift", ".rb", ".php", ".sql", ".csv", ".xml", ".env.example"}}
CAMINHO_ABS = re.compile(r"(?<![\w/])(?:~|/(?:Users|Volumes|tmp|private|opt|var|Applications))(?:/[\w.@+\-()À-ÿ]+)+/?")
CAMINHO_REL = re.compile(r"(?<![\w/~.])(?:[\w.@+\-À-ÿ]+/)+[\w.@+\-À-ÿ]+\.\w{1,5}\b")
CRASE = re.compile(r"`([^`\n]{2,300})`")


def tipo_de(c: Path) -> str:
    if c.is_dir():
        return "pasta"
    ext = c.suffix.lower()
    return next((t for t, exts in TIPOS.items() if ext in exts), "arquivo")


def midias_do_texto(texto: str, pasta: Path, limite: int = 8) -> "list[dict]":
    """Os arquivos e pastas que a mensagem cita e que EXISTEM: caminho absoluto, ~/..., ou relativo à pasta do
    projeto (entre crases ou com barra). `achado` é o texto como apareceu, para a tela virar ele num botão."""
    vistos, fora = set(), []
    candidatos = [m.group(1).strip() for m in CRASE.finditer(texto or "")]
    candidatos += [m.group(0) for m in CAMINHO_ABS.finditer(texto or "")] + [m.group(0) for m in CAMINHO_REL.finditer(texto or "")]
    for achado in candidatos:
        bruto = achado.rstrip(".,;:)!?").strip()
        if not bruto or "\n" in bruto or bruto.startswith(("http://", "https://")):
            continue
        c = Path(os.path.expanduser(bruto))
        if not c.is_absolute():
            if "/" not in bruto and "." not in bruto:
                continue
            c = pasta / c
        try:
            c = c.resolve()
            existe = c.exists()
        except (OSError, RuntimeError):
            continue
        if not existe or str(c) in vistos or c == HOME or c == Path("/"):
            continue
        vistos.add(str(c))
        tipo = tipo_de(c)
        item = {"achado": bruto, "caminho": str(c), "nome": c.name, "tipo": tipo, "url": c.as_uri(),
                "tamanho": c.stat().st_size if c.is_file() else 0}
        if tipo == "codigo" and item["tamanho"] <= 200_000:
            try:
                item["trecho"] = "\n".join(c.read_text("utf-8", errors="replace").splitlines()[:40])[:4000]
            except OSError:
                pass
        fora.append(item)
        if len(fora) >= limite:
            break
    return fora


# Os comandos de / no app. Os do terminal que viram ação da própria janela, e os que não se aplicam a ela.
COMANDOS_DO_APP = {"/clear": "nova", "/chats": "buscar", "/login": "entrar", "/logout": "sair_conta", "/account": "conta",
                   "/mode": "modo", "/api": "modelos", "/config": "ajustes", "/skills": "skills", "/marketplace": "skills",
                   "/attach": "anexar", "/detach": "desanexar", "/copiar": "copiar", "/voice": "voz", "/voice nome": "voz_nome",
                   "/dir": "pasta", "/help": "ajuda", "/effort": "effort", "/permissoes": "permissoes"}
# "perguntar" precisa de alguém no terminal para responder: no app (sem terminal) não se aplica.
PERMISSOES_APP = [("auto", "roda tudo, mas pergunta antes do que não tem volta"), ("livre", "faz tudo sem perguntar"),
                  ("plano", "só lê e planeja, não muda nada")]
COMANDOS_FORA = {"/cls", "/exit", "/uninstall", "/atalhos", "/mascot", "/offline", "/files", "/voice painel", "/voice parar", "!comando"}


def comandos_para_o_app(lista: list) -> list:
    """A lista do terminal (`menu-comandos`) com o tipo de cada um: app (a janela faz), terminal (roda e mostra a
    saída) ou pedido (comando da pessoa: vira um pedido ao agente)."""
    fora = []
    for c in lista:
        nome = str(c.get("nome") or "")
        if not nome or nome in COMANDOS_FORA:
            continue
        item = {"nome": nome, "descricao": str(c.get("descricao") or "")}
        if nome in COMANDOS_DO_APP:
            item.update(tipo="app", acao=COMANDOS_DO_APP[nome])
        else:
            item["tipo"] = "terminal" if c.get("embutido") else "pedido"
        fora.append(item)
    return fora


class Chat:
    """A tela do chat (chat.html): manda e recebe pela ponte `chat` do hud.py. O cérebro é o mesmo da voz
    (`primocode -p ... --output-format json`), com `--resume <id>` para seguir a conversa da nuvem."""

    MAX_ANEXO = 25 * 1024 * 1024

    def __init__(self, app: App) -> None:
        self.app = app
        self.js = lambda m: None
        self.mostrar_voz = lambda: None
        self.parado = False
        app.ao_ouvir = lambda: self.mostrar_voz()

    def raiz(self) -> "Path | None":
        r = Path(self.app.primocode).resolve().parent.parent if self.app.primocode else None
        return r if r and (r / "lib").is_dir() else None

    def node(self, codigo: str, timeout: int = 60) -> dict:
        """Roda um pedaço de JS na raiz do PrimoCode e lê a última linha de JSON que ele imprimir."""
        r = self.raiz()
        if not (self.app.node and r):
            return {"ok": False, "erro": "Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal."}
        try:
            p = subprocess.run([self.app.node, "-e", codigo], cwd=str(r), env=self.app.ambiente(), capture_output=True, text=True, timeout=timeout)
        except Exception as e:
            return {"ok": False, "erro": str(e)}
        for l in reversed(p.stdout.strip().splitlines()):
            try:
                return json.loads(l)
            except ValueError:
                continue
        log("chat node: " + (p.stderr or "")[-300:])
        return {"ok": False, "erro": "O PrimoCode não respondeu."}

    def cli(self, *args: str, timeout: int = 60) -> dict:
        if not (self.app.node and self.app.primocode):
            return {"ok": False, "erro": "Não achei o PrimoCode instalado."}
        try:
            p = subprocess.run([self.app.node, self.app.primocode, *args], cwd=str(self.app.pasta), env=self.app.ambiente(),
                               capture_output=True, text=True, timeout=timeout)
        except Exception as e:
            return {"ok": False, "erro": str(e)}
        for l in reversed(p.stdout.strip().splitlines()):
            try:
                return json.loads(l)
            except ValueError:
                continue
        return {"ok": False, "erro": "O PrimoCode não respondeu."}

    # ── o que a tela mostra ──
    def conta(self) -> dict:
        c = _json(PASTA_PC / "conta.json")
        ate = c.get("planoAte")
        if isinstance(ate, (int, float)):
            ate = time.strftime("%d/%m/%Y", time.localtime(ate / 1000 if ate > 1e11 else ate))
        return {"logado": logado(), "email": c.get("email") or "", "plano": c.get("plano") or "", "planoAte": ate or ""}

    def modo(self) -> str:
        return "chat" if _json(PASTA_PC / "config.json").get("mode") == "chat" else "agent"

    def ajustes(self) -> dict:
        r = self.node("const P=require('./lib/provider/local/provedor.js');const a=P.atual();"
                      "console.log(JSON.stringify({ok:true,atual:a?{id:a.id,nome:a.nome,cli:a.cli,chave:a.chave?P.mascarar(a.chave):''}:null,"
                      "lista:Object.entries(P.PROVEDORES).filter(([,p])=>!p.cli).map(([id,p])=>({id,nome:p.nome}))}))")
        atual = r.get("atual") if r.get("ok") else None
        voz = _json(PASTA_PC / "voz.json")
        cfg = _json(PASTA_PC / "config.json")
        ef = self.node("const E=require('./lib/effort.js');console.log(JSON.stringify({ok:true,niveis:E.NIVEIS.map(n=>({nome:n.nome,resumo:n.resumo}))}))")
        perm = cfg.get("permissoes") if cfg.get("permissoes") in dict(PERMISSOES_APP) else "auto"
        return {"modo": self.modo(),
                "effort": {"atual": str(cfg.get("effort") or "medium"), "niveis": ef.get("niveis") or []},
                "permissoes": {"atual": perm, "opcoes": [{"nome": n, "resumo": r} for n, r in PERMISSOES_APP]},
                "provedor": {"id": atual["id"], "nome": atual["nome"], "chave_mascarada": atual.get("chave") or ""} if atual else {"id": "primo", "nome": "Primo", "chave_mascarada": ""},
                "provedores_chave": r.get("lista") or [], "pasta": str(self.app.pasta), "voz": {"nome": voz.get("nome") or "Primo", "sons": voz.get("sons", True) is not False},
                "conta": self.conta()}

    def conversas(self) -> None:
        r = self.cli("conversas", "--json", "--limite", "40")
        self.js({"conversas": r.get("conversas") or []} if r.get("ok") else {"conversas_erro": r.get("erro") or "Não deu pra carregar as conversas."})

    def skills(self) -> None:
        r = self.node("const S=require('./lib/skill/skills.js');"     # listar() já diz se cada skill está ligada
                      "console.log(JSON.stringify({ok:true,skills:S.listar().map(s=>({nome:s.nome,descricao:s.descricao||'',ligada:s.ligada!==false}))}))")
        self.js({"skills": r.get("skills") or []} if r.get("ok") else {"erro": r.get("erro")})

    # ── a ponte ──
    def receber(self, m: dict) -> None:
        a = m.get("acao")
        trab = None
        if a == "pronto":
            def trab():
                self.js({"inicio": {**self.conta(), "pasta": str(self.app.pasta), "modo": self.modo()}})
                r = self.cli("menu-comandos")
                if r.get("ok"):
                    self.js({"comandos": comandos_para_o_app(r.get("comandos") or [])})
                if logado():
                    self.conversas()
        elif a == "enviar":
            self.parado = False
            trab = lambda: self.pensar(str(m.get("texto") or ""), m.get("conversa"), m.get("modo") or self.modo(), m.get("anexos") or [])
        elif a == "comando" and str(m.get("texto") or "").startswith("/"):
            self.parado = False
            trab = lambda: self.pensar(str(m["texto"]), m.get("conversa"), self.modo(), [], comando=True)
        elif a == "parar":
            self.parado = True
            self.app.matar_cerebro()
        elif a == "conversas":
            trab = self.conversas
        elif a == "abrir" and m.get("id"):
            def trab():
                r = self.cli("conversa", str(m["id"]), "--json")
                if not r.get("ok"):
                    self.js({"erro": r.get("erro") or "Não abriu a conversa."})
                    return
                msgs = [{**x, "midias": midias_do_texto(x.get("texto", ""), self.app.pasta)} for x in (r.get("mensagens") or [])]
                self.js({"conversa": {"id": r.get("id"), "titulo": r.get("titulo"), "mensagens": msgs}})
        elif a == "entrar":
            def trab():
                self.app.entrar_e_esperar()
                self.js({"login": self.conta()})
                if logado():
                    self.conversas()
        elif a == "sair_conta":
            def trab():
                self.node("require('./lib/config/conta.js').sair();console.log('{\"ok\":true}')")
                self.js({"login": self.conta()})
                self.js({"conversas": []})
        elif a == "excluir" and m.get("id"):
            def trab():
                r = self.cli("conversa", str(m["id"]), "--apagar", "--json", timeout=120)
                if not r.get("ok"):
                    self.js({"erro": "Não apaguei a conversa: " + str(r.get("erro") or "sem resposta")})
                self.conversas()
        elif a == "excluir_todas":
            def trab():
                r = self.cli("conversas", "--apagar-todas", "--json", timeout=600)
                if not r.get("ok"):
                    self.js({"erro": "Não apaguei todas: " + str(r.get("erro") or "sem resposta")})
                self.conversas()
        elif a == "skills":
            trab = self.skills
        elif a == "skill" and m.get("nome"):
            def trab():
                self.node(f"require('./lib/skill/skills.js').ligar({json.dumps(str(m['nome']))},{'true' if m.get('ligada') else 'false'});console.log('{{\"ok\":true}}')")
                self.skills()
        elif a == "ajustes":
            trab = lambda: self.js({"ajustes": self.ajustes()})
        elif a == "ajuste":
            trab = lambda: self.ajuste(str(m.get("chave") or ""), m.get("valor"))
        elif a == "soltar":
            trab = lambda: self.soltar(str(m.get("nome") or "arquivo"), str(m.get("base64") or ""))
        if trab:
            threading.Thread(target=trab, daemon=True).start()

    def ajuste(self, chave: str, valor) -> None:
        if chave in ("modo", "effort", "permissoes"):
            cfg = _json(PASTA_PC / "config.json")
            if chave == "modo":
                cfg["mode"] = "chat" if valor == "chat" else "primocode"
            elif chave == "effort" and str(valor) in ("low", "medium", "high", "xhigh", "max", "ultracode"):
                cfg["effort"] = str(valor)
            elif chave == "permissoes" and str(valor) in dict(PERMISSOES_APP):
                cfg["permissoes"] = str(valor)
            else:
                self.js({"erro": f"Valor inválido para {chave}: {valor}"})
            (PASTA_PC / "config.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
        elif chave == "provedor" and isinstance(valor, dict):
            pid = str(valor.get("provedor") or "")
            if pid == "primo":
                r = self.node("require('./lib/provider/local/provedor.js').desligar();console.log('{\"ok\":true}')")
            else:
                r = self.node("const P=require('./lib/provider/local/provedor.js');const p=P.PROVEDORES[" + json.dumps(pid) + "];"
                              "if(!p){console.log(JSON.stringify({ok:false,erro:'Provedor desconhecido.'}));}else{"
                              "P.salvar({provedor:" + json.dumps(pid) + ",chave:" + json.dumps(str(valor.get("chave") or "")) + ",modelo:p.modelos[0]});"
                              "console.log(JSON.stringify({ok:true}))}")
            if not r.get("ok"):
                self.js({"erro": r.get("erro") or "Não deu pra trocar o modelo."})
        elif chave == "voz_nome":
            from primo_voz import identidade
            nome = identidade._nome_valido(str(valor or ""))
            if not nome:
                self.js({"erro": "O nome precisa ter 1 ou 2 palavras, até 20 letras."})
            else:
                self.gravar_voz(nome=nome)
                self.app.js({"nome": nome})
        elif chave == "voz_sons":
            self.gravar_voz(sons=bool(valor))
        self.js({"ajustes": self.ajustes()})

    def gravar_voz(self, **campos) -> None:
        arq = PASTA_PC / "voz.json"
        d = _json(arq)
        d.update(campos)
        arq.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")

    def escolher_pasta(self, caminho: str) -> None:
        d = Path(caminho)
        if d.is_dir():
            self.app.pasta, self.app.pasta_modo = d, "/dir"
            gravar_pasta(d)
            self.app.painel({"pasta": self.app.rotulo()})
            self.js({"pasta": str(d)})

    # ── anexos: tudo vai para <pasta>/anexos e o caminho entra no pedido ──
    def destino(self, nome: str) -> Path:
        pasta = self.app.pasta / "anexos"
        pasta.mkdir(parents=True, exist_ok=True)
        base = re.sub(r"[^\w.\- ]", "_", Path(nome).name).strip() or "arquivo"
        alvo, k = pasta / base, 1
        while alvo.exists():
            alvo = pasta / f"{Path(base).stem}-{k}{Path(base).suffix}"
            k += 1
        return alvo

    def anexar_caminhos(self, caminhos: "list[str]") -> None:
        import shutil
        for c in caminhos:
            o = Path(c)
            if not o.is_file():
                continue
            if o.stat().st_size > self.MAX_ANEXO:
                self.js({"erro": f"{o.name} passa de 25 MB."})
                continue
            alvo = self.destino(o.name)
            shutil.copy2(o, alvo)
            self.js({"anexo": {"nome": o.name, "caminho": str(alvo)}})

    def soltar(self, nome: str, b64: str) -> None:
        import base64
        try:
            dados = base64.b64decode(b64, validate=False)
        except Exception:
            self.js({"erro": f"Não li {nome}."})
            return
        if len(dados) > self.MAX_ANEXO:
            self.js({"erro": f"{nome} passa de 25 MB."})
            return
        alvo = self.destino(nome)
        alvo.write_bytes(dados)
        self.js({"anexo": {"nome": nome, "caminho": str(alvo)}})

    # ── o cérebro ──
    def pensar(self, texto: str, conversa, modo: str, anexos: list, comando: bool = False) -> None:
        """comando=True: `texto` é um /comando do terminal; a saída volta como {saida} (texto de terminal), não como resposta."""
        texto = texto.strip()
        if anexos:
            texto = (texto or "Olha esses arquivos.") + "\n\nAnexos:\n" + "\n".join(f"- {a}" for a in anexos)
        if not texto:
            return
        if not comando and not logado():              # /help, /doctor, /version… funcionam sem conta
            self.js({"login": self.conta()})
            self.js({"erro": "Entre na sua conta do PrimoCode pra conversar."})
            return
        if not (self.app.node and self.app.primocode and Path(self.app.primocode).exists()):
            self.js({"erro": "Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal."})
            return
        with self.app.trava:
            env = self.app.ambiente()
            env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
                        "PRIMOCODE_MODO": "chat" if modo == "chat" else "primocode"})
            if comando and re.match(r"/(open|abrir)\b", texto):
                env["PRIMOCODE_SEM_NAVEGADOR"] = "0"      # /open e /abrir existem para abrir o navegador
            if self.app.pasta != RAIZ:
                env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.app.pasta), ("/dir" if self.app.pasta_modo == "/dir" else "projeto")
            args = [self.app.node, self.app.primocode, "-p", texto, "--output-format", "json"] + (["--resume", str(conversa)] if conversa else [])
            log(f"chat: modo={modo} cwd={self.app.pasta} conversa={conversa or 'nova'}")
            t0 = time.time()
            try:
                p = subprocess.Popen(args, cwd=str(self.app.pasta), env=env, text=True, stdin=subprocess.DEVNULL,
                                     stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
                self.app.cerebro = p
                resto: "list[str]" = []

                def passos():              # cada ferramenta que o agente usa vira uma linha ao vivo no chat
                    for l in p.stderr:
                        if l.startswith("ferramenta:"):
                            self.js({"passo": passo_legivel(l)})
                        elif not abrir_gosto(l):
                            resto.append(l)
                th = threading.Thread(target=passos, daemon=True)
                th.start()
                out = p.stdout.read()
                p.wait(timeout=900)
                th.join(timeout=2)
            except Exception as e:
                log(f"chat: cérebro falhou: {e}")
                self.js({"erro": "O PrimoCode não respondeu."})
                return
        if self.parado:
            self.js({"erro": "Parei."})
            return
        res = {}
        for l in reversed((out or "").strip().splitlines()):
            try:
                res = json.loads(l)
                break
            except ValueError:
                continue
        if comando:
            saida = str(res.get("result") or "").strip()
            if res.get("is_error"):
                self.js({"erro": saida or "O comando falhou."})
            else:
                self.js({"saida": {"comando": texto.split()[0], "texto": saida or "(sem saída)", "conversa": conversa, "midias": midias_do_texto(saida, self.app.pasta)}})
            return
        # o markdown cru da resposta (o -p json manda); sem ele, o texto do terminal sem os enfeites
        txt = str(res.get("markdown") or "").strip() or \
            "\n".join(l for l in str(res.get("result") or "").splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
        log(f"chat: {time.time() - t0:.1f}s, {len(txt)} caracteres, erro={bool(res.get('is_error'))}")
        if res.get("is_error") or not txt:
            self.js({"erro": txt or ("".join(resto)[-300:].strip() or "Não consegui uma resposta agora.")})
            return
        self.js({"resposta": {"texto": txt, "conversa": res.get("conversa") or conversa, "midias": midias_do_texto(txt, self.app.pasta)}})


FERRAMENTA_LEGIVEL = {"read_file": "Lendo", "write_file": "Escrevendo", "edit_file": "Editando", "multi_edit": "Editando",
                      "run_shell": "Rodando", "run_in_new_terminal": "Abrindo um terminal:", "list_dir": "Olhando a pasta",
                      "grep": "Procurando", "glob": "Procurando arquivos", "open_in_browser": "Abrindo no navegador",
                      "pesquisar_referencia": "Pesquisando referências", "write_todos": "Organizando as etapas",
                      "estudio_video": "Montando o vídeo", "estudio_motion": "Animando", "estudio_render": "Renderizando",
                      "estudio_editar": "Editando o vídeo", "estudio_narrar": "Gravando a narração", "estudio_site": "Fazendo o site",
                      "estudio_ae": "Animando no estilo After Effects", "estudio_midia": "Buscando mídia", "estudio_plano": "Planejando",
                      "estudio_projeto": "Abrindo o projeto"}


def passo_legivel(linha: str) -> str:
    """`ferramenta: read_file {"path":"src/a.js"}` → `Lendo src/a.js`. O que não reconhece, mostra o nome."""
    m = re.match(r"ferramenta:\s*(\S+)\s*(.*)", linha.strip())
    if not m:
        return linha.strip()[:120]
    nome, resto = m.group(1), m.group(2)
    try:
        a = json.loads(resto) if resto else {}
    except ValueError:
        a = {}
    alvo = next((str(a[k]) for k in ("path", "file_path", "file", "command", "cmd", "pattern", "query", "url", "dir") if isinstance(a, dict) and a.get(k)), "")
    return (FERRAMENTA_LEGIVEL.get(nome, nome) + (" " + alvo if alvo else "")).strip()[:120]


def main() -> int:
    import fcntl
    import atexit
    PASTA_PC.mkdir(exist_ok=True)
    os.environ["PATH"] = PATH_BASE + ":" + os.environ.get("PATH", "")
    lock = open(PASTA_PC / "primo-app.lock", "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        return 0                      # já aberto: o macOS só traz a janela de volta
    sys.stderr = open(LOG, "a", buffering=1) if (LOG.parent.mkdir(parents=True, exist_ok=True) or True) else sys.stderr
    log("app: abrindo")
    app = App()
    atexit.register(app.sair)
    from primo_voz import hud
    return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair, chat=Chat(app))


if __name__ == "__main__":
    sys.exit(main())
