diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2bd60e..091f152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,4 @@ jobs: - run: python scripts/check_dashboard_pty.py - run: python scripts/check_workspace_pty.py - run: python scripts/check_claude_native_pty.py + - run: python scripts/check_claude_hidden_pty.py diff --git a/Cargo.lock b/Cargo.lock index 5084f02..528ccdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -671,6 +671,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "unicode-width", "uuid", "vt100", ] diff --git a/Cargo.toml b/Cargo.toml index 5456831..0df1e7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,5 @@ ratatui = "0.30.2" crossterm = "0.29" portable-pty = "0.9.0" vt100 = "0.16.2" +unicode-width = "0.2.2" uuid = { version = "1", features = ["serde", "v4"] } diff --git a/README.md b/README.md index d410e66..daa57fe 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,9 @@ cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state cargo run --locked -- workspace --demo --project . --once --no-color ``` -O primeiro recorte real abre o **Claude Code original** em um PTY dentro da TUI. +O primeiro recorte real abre o **Claude Code original** em segundo plano, com +conversa e permissões desenhadas pela Memory Bee. O processo ainda usa um PTY, +mas sua tela não é exibida; hooks oficiais entregam texto e ações à Bee. Faça login pelo próprio `claude` antes; a Memory Bee não solicita nem armazena credenciais. Use um projeto e uma pasta de estado explícitos: @@ -81,13 +83,13 @@ cargo run --locked -- workspace --claude --project . --state /tmp/bee-claude-sta cargo run --locked -- workspace --claude --project . --state /tmp/bee-claude-state --resume ``` -`/exit` encerra o Claude; `Ctrl+G` abre ajuda da Memory Bee e `Ctrl+C` vai ao Claude. +Enter envia mensagem, `Ctrl+C` interrompe e `Ctrl+Q` sai. Quando Claude pedir +permissão, `y` permite uma vez e `n` nega; sem resposta a ponte nega. A pasta privada guarda apenas IDs de sessão e códigos de saída, enquanto o Claude -mantém a conversa nativa. Este recorte ainda mostra a interface do Claude dentro -da moldura Bee. **É uma prova experimental, não a tela final desejada:** o Claude -deve rodar invisível e a Memory Bee deve desenhar toda a conversa e as permissões. -Memória/exportação integrada, perfis isolados e interface própria continuam -pendentes. Contrato e teclas em +mantém a conversa nativa. A interface antiga dentro da moldura continua disponível +para diagnóstico com `--claude-terminal`. A nova tela Bee é funcional, mas ainda +experimental: exportação integrada, perfis isolados, histórico completo na tela +e refinamento visual continuam pendentes. Contrato e teclas em [workspace](docs/specs/workspace.md). O lançamento da experiência completa com Claude e Codex segue condicionado aos aceites do [ADR 0018](docs/decisions/0018-unified-workspace.md). O dashboard anterior permanece disponível. diff --git a/ROADMAP.md b/ROADMAP.md index 9a9ef71..1267ff2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,8 +35,9 @@ por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas 1. Protótipo funcional com os dois agentes simulados, menus, streaming, permissões, erro, persistência e exportação: implementado como `workspace --demo`. 2. Prova real por agente/assinatura, incluindo dois perfis isolados: em andamento. - `workspace --claude` hospeda o binário Claude Code original em PTY e retoma - seu ID nativo; conversa, permissões e perfis reais ainda exigem ensaio humano. + `workspace --claude` hospeda o binário Claude Code original em PTY oculto, + usa hooks para conversa e permissões na Bee e retoma o ID nativo. Mensagem real + curta comprovada; ferramentas/perfis reais ainda exigem ensaio humano. 3. Experiência integrada aos dois harnesses, histórico e exportação: pendente. 4. Troca real entre agentes/contas com revisão e preservação da origem: pendente. 5. Lançamento conjunto após todos os aceites, sem substituir o requisito por @@ -52,14 +53,13 @@ uso da assinatura em um produto de terceiros. A prova deve preservar permissões reais, interrupção, retomada e exportação. Se a interface estruturada Claude não atender a esse contrato, registrar a lacuna antes do lançamento conjunto. -O mantenedor priorizou a implementação nativa **Claude primeiro**. O PTY com -sessão nativa é a primeira entrega desse caminho, disponível como modo experimental; +O mantenedor priorizou a implementação nativa **Claude primeiro**. O PTY oculto +com hooks e UI Bee é a primeira entrega desse caminho, em modo experimental; não altera o aceite do lançamento conjunto da experiência completa. O próximo -recorte deve desenhar a conversa Claude **inteiramente na TUI da Memory Bee**, -com o processo invisível, eventos estruturados, permissões explícitas, histórico -e exportação revisável. A moldura PTY atual é prova técnica, não a UI aceita. -Verificar também se o transporte programático pode usar a assinatura existente -nesse produto; não presumir essa autorização nem trocar por API paga em silêncio. +recorte deve completar histórico/exportação revisável, cobrir prompts nativos que +não emitem hooks e ensaiar ferramentas/permissões reais. A moldura PTY anterior +virou diagnóstico opcional. O transporte atual continua com o binário interativo +original e login próprio; não usa `-p` nem API separada. O HTML ainda representa o plano visual anterior; este Markdown e o ADR 0018 prevalecem para a sequência. diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index 1e2dbf2..9428bdc 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -28,14 +28,13 @@ de patches e consulta ao Git ficam para a etapa 04. ## Próxima tarefa de produto -**Claude invisível na TUI Bee — próxima entrega de produto.** O mantenedor -rejeitou a exibição da tela nativa do Claude. `workspace --claude` hospeda a CLI -em PTY e retoma ID, mas é apenas prova experimental. Próximo: adaptador de eventos -estruturados com Claude em segundo plano, UI Bee de conversa/ações/permissões e -exportação revisável. Testar primeiro com CLI falsa, negando ferramentas sem -decisão explícita. Verificar a fronteira de autenticação para usar assinatura -existente no modo programático antes de habilitá-lo como produto. O modo atual -guarda apenas IDs, não memória; perfis reais e Codex integrado seguem pendentes. +**Claude invisível na TUI Bee — próximo recorte de produto.** O mantenedor +rejeitou a exibição da tela nativa do Claude. `workspace --claude` agora hospeda +a CLI interativa em PTY oculto e usa hooks para mostrar texto, ações e permissões +na Bee. Uma resposta real curta foi recebida na UI; negação/aprovação e retomada +foram testadas com CLI falsa. Próximo: ensaio humano de ferramentas/permissões +reais, tratamento de prompts nativos sem hook e histórico/exportação revisável. +O modo ainda guarda só IDs; perfis reais e Codex integrado seguem pendentes. O lançamento da experiência completa ainda exige os dois agentes (ADR 0018). **Refinamento visual.** Referência HTML e [plano visual](specs/workspace-visual.md) @@ -671,3 +670,37 @@ escopo da entrega atual nem a próxima tarefa aprovada. README. Autorrevisão documental; sem revisão independente. Próximo: prova sintética do adaptador estruturado e da UI Bee, seguida da resolução da fronteira de assinatura e ensaio real autorizado. + +## Claude interativo oculto — primeira interface Bee funcional + +- `workspace --claude` mantém o Claude Code original no PTY sem desenhar sua + tela. Hooks oficiais carregados por `--settings` entregam texto, ações, status + e solicitações de permissão à Bee via socket Unix privado. Decisões exigem + `y`/`n`; falha/timeout negam. `--claude-terminal` preserva o recorte visível + anterior para diagnóstico. Nenhum token é lido pela Memory Bee. +- Evidências: CLI falsa cobriu tela original invisível, mensagens, negar/permitir, + IDs, retomada, restauração do terminal e negação quando o socket falta. + O Claude instalado 2.1.282 foi iniciado em projeto já confiado; hook de sessão + e resposta curta real apareceram na Bee, sem marca nativa visível, e a saída + concluiu com código 0. No projeto descartável não confiado, `SessionStart` + ocorreu mas um prompt nativo oculto impediu a saída normal; limite registrado. +- Autorrevisão; sem revisão independente. Teste manual de ferramentas reais, + permissão, interrupção e retomada permanece pendente (item 52). O histórico + completo ainda não é reidratado na tela e a exportação nativa não está ligada. +- Uma tentativa controlada de solicitar ação Bash real não produziu um pedido + observável na Bee dentro do prazo; nenhum arquivo temporário foi criado. Esse + ensaio é inconclusivo para o fluxo de permissão real, não um aceite. +- Próximo: cobrir estados nativos invisíveis de confiança/login com falha clara + ou ação explícita, validar ferramentas/permissões reais e ligar a sessão Claude + à exportação revisável. Não declarar o fluxo completo pronto nesta etapa. +- Retomada deste recorte em 2026-09-25: `cargo fmt --all -- --check`, `cargo + test --locked`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo + build --locked` e testes PTY sintéticos da moldura nativa e da UI oculta + passaram. O teste oculto agora cobre `Ctrl+Q` com permissão pendente, que deve + negar antes de sair. A suíte Rust exigiu execução fora do sandbox para criar + um socket Unix; o mesmo valeu para o teste PTY da ponte. Links relativos e + `git diff --check` conferidos. Autorrevisão, sem revisão independente. +- Corrigida a decisão por teclado: somente `y`, `n` ou Esc resolvem uma + permissão; `Ctrl+C` e `Ctrl+Q` negam antes de interromper/sair. O roteiro 51 + identifica `--claude-terminal` como diagnóstico. Ainda não há prova de + ferramentas e permissões reais além da resposta curta já observada. diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index 2ddc5ce..efebef4 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -840,7 +840,7 @@ cor, tema claro/escuro, larguras 140/100/60/40 e janela estreita. A cena inicial continua disponível. Nenhum teste, arquivo ou agente real é executado pelo HTML. Esse roteiro não valida o renderizador Rust, ainda pendente. -## 51. Claude Code nativo dentro da Memory Bee (pendente) +## 51. Claude Code nativo na moldura de diagnóstico (pendente) Em um projeto descartável, faça login primeiro pelo Claude Code original e confirme que ele funciona fora da Memory Bee. Depois rode: @@ -848,8 +848,8 @@ confirme que ele funciona fora da Memory Bee. Depois rode: ```sh cargo build --locked mkdir -p /tmp/bee-claude-project -target/debug/memory-bee workspace --claude --project /tmp/bee-claude-project --state /tmp/bee-claude-state -target/debug/memory-bee workspace --claude --project /tmp/bee-claude-project --state /tmp/bee-claude-state --resume +target/debug/memory-bee workspace --claude --claude-terminal --project /tmp/bee-claude-project --state /tmp/bee-claude-state +target/debug/memory-bee workspace --claude --claude-terminal --project /tmp/bee-claude-project --state /tmp/bee-claude-state --resume ``` Na primeira sessão, enviar tarefa simples, observar resposta e, só no projeto @@ -862,10 +862,42 @@ ou conteúdo da conversa no repositório. Estado com outro projeto e chamada sem TTY devem ser recusados. Anotar versão do Claude, resultado de cada ação e se houve uso da assinatura já autenticada. -O teste automatizado sintético, sem login/modelo, é +Este modo mantém a tela nativa visível apenas para diagnóstico. O teste +automatizado sintético, sem login/modelo, é `python3 scripts/check_claude_native_pty.py`; aprovação nele não conclui este ensaio. Exportação da memória pelo modo nativo e perfis isolados seguem pendentes. | Item novo | Estado | Evidência manual | |---|---|---| | 51: conversa, permissões, interrupção e retomada Claude reais | Pendente | — | + +## 52. Claude oculto com interface da Bee (pendente) + +Antes, abra `claude` no projeto escolhido para concluir login e confirmação de +confiança por ele. Depois rode em um projeto descartável já confiado: + +```sh +cargo build --locked +target/debug/memory-bee workspace --claude --project /caminho/do/projeto --state /tmp/bee-claude-hidden +target/debug/memory-bee workspace --claude --project /caminho/do/projeto --state /tmp/bee-claude-hidden --resume +``` + +Esperado: apenas moldura e conversa da Memory Bee; logo, campo de entrada e +permissões originais do Claude não aparecem. Enviar mensagem curta e conferir +resposta, pedir leitura, edição e execução no projeto descartável. Para cada +solicitação, verificar que `n` nega e `y` permite somente a ação exibida. +Testar `Ctrl+C`, colagem, resize, `Ctrl+Q`, retomada e restauração do terminal. +Com permissão pendente, conferir que outra tecla não decide e que `Ctrl+C` ou +`Ctrl+Q` negam antes de interromper ou sair. +Confirmar que a sessão continua acessível no Claude original. Registrar versão +e resultados, sem copiar dados reais da conversa para o repositório. + +Prompts de login/confiança não cobertos por hook podem ficar invisíveis; se a +tela não avançar, sair com `Ctrl+Q` e preparar o projeto diretamente no Claude. +`--claude-terminal` mostra o modo antigo apenas para diagnóstico. O teste +sintético é `python3 scripts/check_claude_hidden_pty.py`; não substitui este +ensaio humano. Exportação integrada e perfis reais ainda pendentes. + +| Item novo | Estado | Evidência manual | +|---|---|---| +| 52: UI Bee sobre Claude real oculto, ferramentas, permissões e retomada | Pendente | — | diff --git a/docs/decisions/0018-unified-workspace.md b/docs/decisions/0018-unified-workspace.md index e8a56f5..0a87b68 100644 --- a/docs/decisions/0018-unified-workspace.md +++ b/docs/decisions/0018-unified-workspace.md @@ -157,3 +157,24 @@ de produto pretendido. Não prometer essa combinação como suportada nem coleta tokens. Confirmar a fronteira antes de lançar a TUI própria com assinatura; se não for permitida, apresentar a limitação ao mantenedor sem substituir silenciosamente a assinatura por cobrança de API. + +## Resolução de transporte — Claude interativo oculto com hooks + +O primeiro recorte da interface Bee foi implementado mantendo **o binário +interativo original**, autenticado pelo próprio usuário. O Claude recebe um PTY +oculto para manter a sessão e o fluxo nativo; a Bee não desenha os bytes dessa +tela. Hooks de sessão, texto, ferramentas e permissões entregam eventos por +socket Unix privado à TUI. `PermissionRequest` aguarda decisão `y`/`n`; erro, +timeout ou segunda solicitação simultânea recebem `deny`. `Ctrl+C` é enviado ao +processo original. O modo anterior com terminal visível fica em +`--claude-terminal` apenas para diagnóstico. + +Esta escolha evita `-p`/Agent SDK e mantém a condição de hospedar o CLI original +com login feito nele. A [referência de hooks](https://code.claude.com/docs/en/hooks) +documenta `MessageDisplay` e `PermissionRequest` também na sessão interativa. +Um ensaio real de mensagem curta confirmou texto na Bee sem exibir a tela nativa; +aprovação/negação e retomada passaram com CLI falsa. Não inferir dessa prova que +todos os prompts nativos, ferramentas, contas e exportação estejam cobertos. +Prompts de login ou confiança que não emitam hook podem ficar invisíveis; o +usuário deve completar essa preparação no Claude original. Até existir tratamento +fiel desses casos e ensaio real de permissões, o modo continua experimental. diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 31b3007..06a3f8b 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -2,15 +2,16 @@ Contrato dos primeiros recortes do [ADR 0018](../decisions/0018-unified-workspace.md). `workspace --demo` não é programação real: Claude/Codex, ferramentas, mudanças e -permissões são simulados. `workspace --claude` inicia o Claude Code instalado. +permissões são simulados. `workspace --claude` inicia o Claude Code instalado em +segundo plano e desenha conversa/ações na TUI Bee. Para a versão real, a TUI deverá hospedar os CLIs oficiais como processos locais. O usuário autentica cada um pelo seu próprio fluxo. A Memory Bee não lê nem armazena tokens; associa sessões, eventos e exportações ao processo e ao perfil selecionado. App Server é o transporte candidato para Codex; para Claude, testar o binário original em PTY e verificar se a CLI permite uma conversa própria com -permissões fiéis. O PTY é o primeiro transporte Claude implementado; ainda não -fornece eventos estruturados nem conversa renderizada pela Memory Bee. +permissões fiéis. O PTY oculto e hooks oficiais fornecem o primeiro transporte +Claude com eventos para uma conversa Bee, ainda experimental. ## Claude local experimental @@ -21,26 +22,39 @@ memory-bee workspace --claude --project . --state /tmp/bee-claude-state --resume Exige `claude` no PATH, TTY interativo, projeto existente e pasta de estado privada (pai existente). Inicia o binário original com `--session-id ` ou -`--resume `, sem copiar tokens. O login, a conversa, as ferramentas e as -decisões de permissão pertencem ao Claude Code. A Memory Bee encaminha teclado, -colagem e resize ao PTY, desenha sua saída em moldura própria e restaura o terminal -ao sair. `Ctrl+G` abre ajuda Bee; `Ctrl+C` vai ao Claude; `/exit` encerra a sessão. +`--resume `, sem copiar tokens. A Memory Bee usa `--settings` apenas na +invocação para registrar hooks `SessionStart`, `MessageDisplay`, `PreToolUse`, +`PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Stop` e `StopFailure`. +O Claude continua controlando login, sessão e execução. Um socket Unix em pasta +temporária 0700 recebe eventos; o subprocesso de hook devolve decisão `allow` ou +`deny` ao Claude. Falha de ponte e timeout negam permissões. A tela nativa fica +oculta; a Bee mostra mensagens, nomes de ações e decisão `y`/`n`, com negação +inicial. Enter envia prompt, `Ctrl+C` interrompe e `Ctrl+Q` encerra. O terminal +volta ao estado anterior na saída. `--claude-terminal` reabre o modo anterior com +a TUI nativa visível para diagnóstico. Se houver permissão pendente, `Ctrl+C` e +`Ctrl+Q` negam antes de interromper ou sair; outras teclas além de `y`, `n` e +Esc não decidem a permissão. +O painel mostra o `tool_input` completo; quando não cabe para revisão, `y` também +nega. `Ctrl+Q` aguarda até cinco segundos por `/exit` e encerra o processo se +o CLI nativo estiver preso em um prompt invisível. `claude-native.json` guarda projeto canônico, IDs e códigos de saída (até 128 sessões, 64 KiB), sem transcrição. A pasta é 0700 e arquivos são 0600 em Unix; `claude-native.lock` impede segundo escritor. Após crash, verificar que não há processo ativo antes de remover a trava antiga. `--resume` usa o último ID desta pasta; não reenvia prompt. A sessão nativa pode não existir quando se sai antes -de enviar uma mensagem. Exportação/revisão de memória, isolamento de perfis, -decisões em UI própria e integração Codex estão pendentes. O modo não comprova +de enviar uma mensagem. Exportação/revisão de memória, isolamento de perfis e +integração Codex estão pendentes. O modo não comprova conversa/permissões reais por si só; ver [roteiro manual](../MANUAL_TESTS.md). -**Direção atual:** o mantenedor não quer ver a TUI original do Claude. Este -terminal embutido permanece apenas como recorte experimental. A UI aceita deve -mostrar somente componentes da Memory Bee e receber eventos estruturados do -Claude em segundo plano, inclusive solicitações de permissão. Antes de ligar -assinaturas ao modo programático, resolver o limite de autenticação documentado -no [ADR 0018](../decisions/0018-unified-workspace.md). +**Direção atual:** o mantenedor não quer ver a TUI original do Claude. O modo +padrão `--claude` já oculta essa tela e mostra componentes da Memory Bee. O +terminal embutido anterior requer `--claude-terminal`. A UI Bee ainda é um +recorte funcional sem histórico/exportação nativos integrados; prompts de login, +confiança do projeto ou outros controles do Claude que não emitam hooks podem +ficar ocultos e bloquear a sessão. Preparar o projeto no Claude original antes +de usar a Bee e conferir o [roteiro manual](../MANUAL_TESTS.md). O [ADR 0018](../decisions/0018-unified-workspace.md) +registra por que o transporte mantém o binário interativo sem usar `-p`. ```sh cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state @@ -126,5 +140,8 @@ colagem, decisão negada, exportação/schema/verify, resize, interrupção e re em PTY com projeto sintético. O [roteiro manual](../MANUAL_TESTS.md) mantém ensaios humanos separados. `scripts/check_claude_native_pty.py` usa CLI falsa para testar entrada, ID, retomada, estado privado e restauração do terminal. Adaptador Codex -experimental é apenas protocolo não conectado; ensaio de conversa Claude real, -contas, isolamento real, exportação nativa e lançamento conjunto pendentes. +experimental é apenas protocolo não conectado. `scripts/check_claude_hidden_pty.py` +cobre a UI Bee sobre CLI falsa com hooks, texto nativo oculto, decisões de +permissão, retomada e negação na falha da ponte. Uma mensagem curta real chegou +à Bee; ferramentas/permissões reais, contas, isolamento, exportação nativa e +lançamento conjunto seguem pendentes. diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py new file mode 100644 index 0000000..652843f --- /dev/null +++ b/scripts/check_claude_hidden_pty.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Exercise the Bee-only Claude UI with synthetic official-hook events.""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import struct +import subprocess +import tempfile +import termios +import time + + +FAKE = r'''#!/usr/bin/env python3 +import json, os, subprocess, sys +args = sys.argv[1:] +session_id = args[args.index('--resume') + 1] if '--resume' in args else args[args.index('--session-id') + 1] +settings = json.loads(args[args.index('--settings') + 1]) +assert all(name in settings['hooks'] for name in ('SessionStart','MessageDisplay','PermissionRequest')) +with open(os.environ['BEE_FAKE_ARGS'], 'a') as f: + f.write(f'{args[0]}|{session_id}|{os.getcwd()}\n') +with open(os.environ['BEE_SOCKETS'], 'a') as f: + f.write(os.environ['MEMORY_BEE_CLAUDE_SOCKET'] + '\n') +def hook(name, **fields): + payload = json.dumps(dict(hook_event_name=name, session_id=session_id, **fields)) + return subprocess.run([os.environ['BEE_BINARY'], '__claude-hook'], input=payload, text=True, capture_output=True, check=True).stdout +print('CLAUDE ORIGINAL SHOULD STAY HIDDEN', flush=True) +hook('SessionStart') +prompt = sys.stdin.readline().strip() +hook('UserPromptSubmit', prompt=prompt) +hook('MessageDisplay', delta='resposta sintetica', final=True, index=0) +decision = json.loads(hook('PermissionRequest', tool_name='Bash', tool_input={'command':'echo synthetic'})) +with open(os.environ['BEE_DECISIONS'], 'a') as f: + f.write(decision['hookSpecificOutput']['decision']['behavior'] + '\n') +hook('Stop') +''' + + +def run(binary, project, state, env, resume, decision): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + before = termios.tcgetattr(slave) + argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] + if resume: + argv.append('--resume') + proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=env) + output = bytearray() + + def until(token): + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + output.extend(os.read(master, 65536)) + except OSError: + break + if token in output: + return + if proc.poll() is not None: + break + raise AssertionError(f'missing Bee UI token {token!r}; process exit={proc.poll()}; Bee output={output[-400:]!r}') + + try: + until(b'pronto.') + os.write(master, b'hello\r') + until(b'Permitir esta') + os.write(master, decision.encode()) + until(b'sintetica') + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired as exc: + raise AssertionError(f'Bee did not exit after {decision!r}; output={output[-800:]!r}') from exc + assert proc.returncode == 0, proc.returncode + assert b'CLAUDE ORIGINAL SHOULD STAY HIDDEN' not in output + assert termios.tcgetattr(slave) == before + assert not (state / 'claude-native.lock').exists() + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + + +def main(): + repo = Path(__file__).resolve().parent.parent + binary = repo / 'target/debug/memory-bee' + with tempfile.TemporaryDirectory(prefix='bee-hidden-') as temp: + root = Path(temp) + fake_dir = root / 'bin' + fake_dir.mkdir() + fake = fake_dir / 'claude' + fake.write_text(FAKE) + fake.chmod(0o700) + project = root / 'project' + project.mkdir() + state = root / 'state' + args_file = root / 'args' + decisions = root / 'decisions' + sockets = root / 'sockets' + env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', BEE_BINARY=str(binary), BEE_FAKE_ARGS=str(args_file), BEE_DECISIONS=str(decisions), BEE_SOCKETS=str(sockets)) + run(binary, project, state, env, False, 'n') + session = json.loads((state / 'claude-native.json').read_text())['sessions'][0]['id'] + run(binary, project, state, env, True, 'y') + run(binary, project, state, env, True, '\x11') # Ctrl+Q denies pending permission before exit. + assert decisions.read_text().splitlines() == ['deny', 'allow', 'deny'] + assert args_file.read_text().splitlines() == [f'--session-id|{session}|{project.resolve()}', f'--resume|{session}|{project.resolve()}', f'--resume|{session}|{project.resolve()}'] + assert len(json.loads((state / 'claude-native.json').read_text())['sessions']) == 1 + assert all(not Path(path).exists() for path in sockets.read_text().splitlines()) + denied = subprocess.run([str(binary), '__claude-hook'], input=json.dumps({'hook_event_name':'PermissionRequest'}), text=True, capture_output=True, env=dict(env, MEMORY_BEE_CLAUDE_SOCKET=str(root / 'missing.sock'))) + assert denied.returncode == 0 + assert json.loads(denied.stdout)['hookSpecificOutput']['decision']['behavior'] == 'deny' + print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume and terminal restoration') + + +if __name__ == '__main__': + main() diff --git a/scripts/check_claude_native_pty.py b/scripts/check_claude_native_pty.py index 15670bc..0c59608 100644 --- a/scripts/check_claude_native_pty.py +++ b/scripts/check_claude_native_pty.py @@ -17,7 +17,7 @@ def one_run(binary, project, state, env, resume=False): master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 20, 80, 0, 0)) before = termios.tcgetattr(slave) - argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] + argv = [str(binary), 'workspace', '--claude', '--claude-terminal', '--project', str(project), '--state', str(state), '--no-color'] if resume: argv.append('--resume') proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=env) diff --git a/src/main.rs b/src/main.rs index bb32e5c..8b13dc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use std::{ process::ExitCode, }; -const USAGE: &str = "Usage:\n memory-bee workspace --demo --project (--state | --once [--width ] [--height ]) [--agent claude|codex] [--theme dark|light] [--no-color]\n memory-bee workspace --claude --project --state [--resume] [--theme dark|light] [--no-color]\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nInspection and export are offline. workspace --claude starts the installed Claude Code CLI.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; +const USAGE: &str = "Usage:\n memory-bee workspace --demo --project (--state | --once [--width ] [--height ]) [--agent claude|codex] [--theme dark|light] [--no-color]\n memory-bee workspace --claude [--claude-terminal] --project --state [--resume] [--theme dark|light] [--no-color]\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nInspection and export are offline. workspace --claude starts the installed Claude Code CLI.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; fn output(value: &impl Serialize) -> Result<(), (u8, String)> { let stdout = io::stdout(); let mut out = stdout.lock(); @@ -116,6 +116,10 @@ fn run() -> Result { if args.first().is_some_and(|arg| arg == "workspace") { return workspace(&args[1..]); } + #[cfg(unix)] + if args.len() == 1 && args[0] == "__claude-hook" { + return Ok(tui::claude_native::hook_main()); + } if args.first().is_some_and(|arg| arg == "dashboard") { return dashboard(&args[1..]); } @@ -529,6 +533,7 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { let mut state: Option = None; let mut demo = false; let mut claude_live = false; + let mut claude_terminal = false; let mut resume = false; let mut once = false; let mut agent = Agent::Claude; @@ -545,6 +550,7 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { match flag { "--demo" => demo = true, "--claude" => claude_live = true, + "--claude-terminal" => claude_terminal = true, "--resume" => resume = true, "--once" => once = true, "--no-color" => no_color = true, @@ -584,7 +590,7 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } i += 1; } - if (demo && claude_live) || (resume && !claude_live) { + if (demo && claude_live) || (resume && !claude_live) || (claude_terminal && !claude_live) { return Err((64, USAGE.into())); } if !demo && !claude_live { @@ -616,9 +622,25 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } let state_path = state.ok_or((64, "workspace --claude exige --state ".into()))?; - let (id, code) = + let (id, code) = if claude_terminal { tui::claude_native::run(Path::new(&project), &state_path, resume, mode, no_color) - .map_err(|e| (1, e))?; + } else { + #[cfg(unix)] + { + tui::claude_native::run_hidden( + Path::new(&project), + &state_path, + resume, + mode, + no_color, + ) + } + #[cfg(not(unix))] + { + Err("Interface Claude oculta exige macOS ou Linux".into()) + } + } + .map_err(|e| (1, e))?; println!("Claude Code encerrou. Sessão nativa: {id}"); return Ok(if code == 0 { 0 } else { 4 }); } diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 0b9d3ed..a719df8 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -19,14 +19,25 @@ use ratatui::{ }; use std::{ ffi::OsStr, - io::{self, Read, Write}, + io::{self, BufRead, Read, Write}, path::Path, sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant}, }; +#[cfg(unix)] +use unicode_width::UnicodeWidthStr; use uuid::Uuid; +#[cfg(unix)] +use std::{ + os::unix::net::{UnixListener, UnixStream}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + enum Output { Bytes(Vec), Closed, @@ -47,6 +58,7 @@ impl Pty { resume: bool, size: PtySize, no_color: bool, + bridge: Option<(&Path, &str)>, ) -> Result { let pair = NativePtySystem::default() .openpty(size) @@ -60,6 +72,13 @@ impl Pty { command.arg("--session-id"); } command.arg(id.to_string()); + if let Some((socket, settings)) = bridge { + command.env("MEMORY_BEE_CLAUDE_SOCKET", socket.as_os_str()); + command.arg("--settings"); + command.arg(settings); + command.arg("--permission-mode"); + command.arg("default"); + } if no_color { command.env("NO_COLOR", "1"); } @@ -375,6 +394,7 @@ pub fn run( resume, pty_size(rows, cols), no_color, + None, )?; if !resume { store.start(&mut state, id)?; @@ -478,6 +498,525 @@ pub fn run( Ok((id, code)) } +#[cfg(unix)] +struct BridgeEvent { + value: serde_json::Value, + reply: Option>, +} + +#[cfg(unix)] +struct Bridge { + directory: std::path::PathBuf, + path: std::path::PathBuf, + stop: Arc, + events: Receiver, +} + +#[cfg(unix)] +impl Bridge { + fn start() -> Result { + // macOS limits Unix socket paths to roughly 100 bytes. + let directory = Path::new("/tmp").join(format!("bee-{}", Uuid::new_v4())); + let mut builder = std::fs::DirBuilder::new(); + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + builder.create(&directory).map_err(|e| e.to_string())?; + let path = directory.join("hook.sock"); + let listener = UnixListener::bind(&path).map_err(|e| e.to_string())?; + listener.set_nonblocking(true).map_err(|e| e.to_string())?; + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + let (tx, events) = mpsc::channel(); + thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let tx = tx.clone(); + thread::spawn(move || handle_hook_connection(stream, tx)); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + }); + Ok(Self { + directory, + path, + stop, + events, + }) + } +} + +#[cfg(unix)] +impl Drop for Bridge { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + let _ = std::fs::remove_file(&self.path); + let _ = std::fs::remove_dir(&self.directory); + } +} + +#[cfg(unix)] +fn deny_json() -> String { + serde_json::json!({"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Memory Bee não recebeu aprovação explícita"}}}).to_string() +} + +#[cfg(unix)] +fn handle_hook_connection(mut stream: UnixStream, tx: mpsc::Sender) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut bytes = Vec::new(); + let read = io::BufReader::new(&stream) + .take(256 * 1024 + 1) + .read_until(b'\n', &mut bytes); + if read.is_err() || bytes.len() > 256 * 1024 { + return; + } + let Ok(value) = serde_json::from_slice::(&bytes) else { + return; + }; + let permission = value["hook_event_name"] == "PermissionRequest"; + if permission { + let (reply_tx, reply_rx) = mpsc::channel(); + if tx + .send(BridgeEvent { + value, + reply: Some(reply_tx), + }) + .is_err() + { + return; + } + let allowed = reply_rx + .recv_timeout(Duration::from_secs(90)) + .unwrap_or(false); + let decision = if allowed { + serde_json::json!({"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}).to_string() + } else { + deny_json() + }; + let _ = stream.write_all(decision.as_bytes()); + let _ = stream.write_all(b"\n"); + } else { + let _ = tx.send(BridgeEvent { value, reply: None }); + } +} + +/// Entry point invoked by Claude Code's command hooks. Never prints hook input. +#[cfg(unix)] +pub fn hook_main() -> u8 { + let mut bytes = Vec::new(); + if io::stdin() + .take(256 * 1024 + 1) + .read_to_end(&mut bytes) + .is_err() + || bytes.len() > 256 * 1024 + { + println!("{}", deny_json()); + return 0; + } + let permission = serde_json::from_slice::(&bytes) + .ok() + .is_some_and(|v| v["hook_event_name"] == "PermissionRequest"); + let response = std::env::var_os("MEMORY_BEE_CLAUDE_SOCKET") + .map(std::path::PathBuf::from) + .and_then(|path| UnixStream::connect(path).ok()) + .and_then(|mut stream| { + stream + .set_read_timeout(Some(Duration::from_secs(95))) + .ok()?; + stream.write_all(&bytes).ok()?; + stream.write_all(b"\n").ok()?; + if permission { + let mut reply = String::new(); + io::BufReader::new(stream).read_line(&mut reply).ok()?; + serde_json::from_str::(&reply).ok()?; + Some(reply) + } else { + Some(String::new()) + } + }); + if permission { + print!("{}", response.unwrap_or_else(deny_json)); + } + 0 +} + +#[cfg(unix)] +fn hook_settings(executable: &Path) -> String { + let hook = serde_json::json!({"type":"command","command":executable,"args":["__claude-hook"],"timeout":120}); + let mut hooks = serde_json::Map::new(); + for event in [ + "SessionStart", + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionRequest", + "Stop", + "StopFailure", + ] { + hooks.insert(event.into(), serde_json::json!([{"hooks":[hook.clone()]}])); + } + serde_json::json!({"hooks":hooks}).to_string() +} + +#[cfg(unix)] +struct HiddenView { + lines: Vec, + input: String, + pending: Option<(String, mpsc::Sender)>, + ready: bool, + quitting: bool, +} + +#[cfg(unix)] +impl HiddenView { + fn new() -> Self { + Self { + lines: vec!["Iniciando Claude Code em segundo plano…".into()], + input: String::new(), + pending: None, + ready: false, + quitting: false, + } + } + fn push(&mut self, line: impl Into) { + self.lines.push(line.into()); + if self.lines.len() > 2000 { + self.lines.drain(..1000); + } + } + fn receive(&mut self, event: BridgeEvent, id: Uuid) { + if event.value["session_id"].as_str() != Some(&id.to_string()) { + if let Some(reply) = event.reply { + let _ = reply.send(false); + } + return; + } + match event.value["hook_event_name"].as_str().unwrap_or("") { + "SessionStart" => { + self.ready = true; + self.push("Claude pronto. Digite sua mensagem abaixo."); + } + "MessageDisplay" => { + if let Some(delta) = event.value["delta"].as_str() { + for line in delta.lines() { + self.push(format!("Claude: {line}")); + } + } + } + "PreToolUse" => self.push(format!( + "Ação: {}", + event.value["tool_name"].as_str().unwrap_or("desconhecida") + )), + "PostToolUse" => self.push(format!( + "Concluído: {}", + event.value["tool_name"].as_str().unwrap_or("ação") + )), + "PostToolUseFailure" => self.push(format!( + "Falhou: {}", + event.value["tool_name"].as_str().unwrap_or("ação") + )), + "PermissionRequest" => { + let name = event.value["tool_name"].as_str().unwrap_or("ação"); + let details = serde_json::to_string_pretty(&event.value["tool_input"]) + .unwrap_or_else(|_| "".into()); + let request = format!("Ferramenta: {name}\nEntrada completa:\n{details}"); + self.push(format!("Permissão solicitada: {name}")); + if let Some(reply) = event.reply { + if self.pending.is_some() { + let _ = reply.send(false); + } else { + self.pending = Some((request, reply)); + } + } + } + "Stop" => self.push("Turno concluído."), + "StopFailure" => self.push("Claude encerrou o turno com erro."), + _ => {} + } + } +} + +#[cfg(unix)] +fn approval_fits(request: &str, width: u16, height: u16) -> bool { + if width < 40 || height < 12 || request.chars().count() > 2048 { + return false; + } + let text_width = width.saturating_sub(8).max(1) as usize; + let text_rows = height.saturating_sub(10) as usize; + request + .lines() + .map(|line| UnicodeWidthStr::width(line).max(1).div_ceil(text_width)) + .sum::() + <= text_rows +} + +#[cfg(unix)] +fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) { + let area = frame.area(); + let palette = Palette::new(mode, no_color); + let bg = if mode == Mode::Dark { + Color::Black + } else { + Color::White + }; + frame.render_widget(ratatui::widgets::Clear, area); + frame.render_widget(Block::default().style(Style::default().bg(bg)), area); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + frame.render_widget( + Paragraph::new("⬢ Memory Bee · Claude em segundo plano").style( + Style::default() + .fg(palette.accent) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + rows[0], + ); + let visible = rows[1].height.saturating_sub(2) as usize; + let start = view.lines.len().saturating_sub(visible); + frame.render_widget( + Paragraph::new(view.lines[start..].join("\n")) + .block( + Block::default() + .title(" Conversa ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)), + ) + .style(Style::default().bg(bg)), + rows[1], + ); + let prompt = if view.pending.is_some() { + "Permissão pendente · veja o painel de revisão".into() + } else if !view.ready { + "Aguardando Claude iniciar…".into() + } else { + format!("> {}", view.input) + }; + frame.render_widget( + Paragraph::new(prompt) + .block( + Block::default() + .title(" Mensagem / decisão ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)), + ) + .style(Style::default().bg(bg)), + rows[2], + ); + frame.render_widget( + Paragraph::new("Enter envia · Ctrl+C interrompe · Ctrl+Q sai · sem aprovação Bee, negar") + .style(Style::default().bg(bg)), + rows[3], + ); + if let Some((request, _)) = &view.pending { + let modal = Rect::new( + 2, + 2, + area.width.saturating_sub(4), + area.height.saturating_sub(4), + ); + frame.render_widget(ratatui::widgets::Clear, modal); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(" Revisar permissão Claude ") + .style(Style::default().bg(bg)) + .border_style(Style::default().fg(palette.accent)), + modal, + ); + let inner = Rect::new( + modal.x + 2, + modal.y + 1, + modal.width.saturating_sub(4), + modal.height.saturating_sub(3), + ); + frame.render_widget( + Paragraph::new(request.as_str()) + .wrap(ratatui::widgets::Wrap { trim: false }) + .style(Style::default().bg(bg)), + inner, + ); + let action = if approval_fits(request, area.width, area.height) { + "[y] Permitir esta ação uma vez · [n] Negar (padrão)" + } else { + "Entrada não cabe para revisão: [n] Negar; y também nega" + }; + frame.render_widget( + Paragraph::new(action).style(Style::default().bg(bg)), + Rect::new( + modal.x + 2, + modal.bottom().saturating_sub(2), + modal.width.saturating_sub(4), + 1, + ), + ); + } +} + +/// First native Bee conversation surface. Claude's own terminal stays private. +#[cfg(unix)] +pub fn run_hidden( + project: &Path, + root: &Path, + resume: bool, + mode: Mode, + no_color: bool, +) -> Result<(Uuid, u32), String> { + let (store, mut state) = ClaudeStore::open(root, project)?; + let id = if resume { + state + .latest() + .ok_or("Nenhuma sessão Claude anterior nesta pasta")? + } else { + Uuid::new_v4() + }; + let bridge = Bridge::start()?; + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let settings = hook_settings(&executable); + let _guard = TerminalGuard::enter()?; + let mut terminal = + Terminal::new(CrosstermBackend::new(io::stdout())).map_err(|e| e.to_string())?; + let size = terminal.size().map_err(|e| e.to_string())?; + if size.width < 40 || size.height < 10 { + return Err("Terminal exige pelo menos 40×10".into()); + } + let mut pty = Pty::start( + OsStr::new("claude"), + project, + id, + resume, + pty_size(size.height, size.width), + no_color, + Some((&bridge.path, &settings)), + )?; + if !resume { + store.start(&mut state, id)?; + } + let mut view = HiddenView::new(); + let mut status = None; + let mut quit_at = None; + let mut dirty = true; + loop { + while let Ok(event) = bridge.events.try_recv() { + view.receive(event, id); + dirty = true; + } + // Drain native terminal bytes without displaying or parsing its UI. + while pty.output.try_recv().is_ok() {} + if dirty { + terminal + .draw(|f| draw_hidden(f, &view, mode, no_color)) + .map_err(|e| e.to_string())?; + dirty = false; + } + if status.is_none() + && let Some(exit) = pty.try_wait()? + { + status = Some(exit.exit_code()); + } + if status.is_none() + && quit_at.is_some_and(|at: Instant| at.elapsed() > Duration::from_secs(5)) + { + pty.child.kill().map_err(|e| e.to_string())?; + let exit = pty.child.wait().map_err(|e| e.to_string())?; + pty.exited = true; + status = Some(exit.exit_code()); + } + if status.is_some() { + break; + } + if event::poll(Duration::from_millis(40)).map_err(|e| e.to_string())? { + match event::read().map_err(|e| e.to_string())? { + Event::Key(key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { + if let Some((_, reply)) = view.pending.take() { + let _ = reply.send(false); + view.push("Permissão negada ao interromper."); + } + pty.write(&[3])?; + view.push("Interrupção enviada ao Claude."); + } else if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('q') + { + if let Some((_, reply)) = view.pending.take() { + let _ = reply.send(false); + view.push("Permissão negada ao sair."); + } + pty.write(b"/exit\r")?; + view.quitting = true; + quit_at = Some(Instant::now()); + view.push("Encerrando Claude…"); + } else if view.pending.is_some() + && matches!(key.code, KeyCode::Char('y' | 'n') | KeyCode::Esc) + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + let (request, reply) = view.pending.take().expect("checked pending"); + let size = terminal.size().map_err(|e| e.to_string())?; + let allow = key.code == KeyCode::Char('y') + && approval_fits(&request, size.width, size.height); + let _ = reply.send(allow); + view.push(if allow { + "Permissão concedida uma vez." + } else { + "Permissão negada." + }); + } else if view.pending.is_none() && view.ready && !view.quitting { + match key.code { + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + view.input.push(c) + } + KeyCode::Backspace => { + view.input.pop(); + } + KeyCode::Enter if !view.input.trim().is_empty() => { + let prompt = std::mem::take(&mut view.input); + pty.write(prompt.as_bytes())?; + pty.write(b"\r")?; + view.push(format!("Você: {prompt}")); + } + _ => {} + } + } + dirty = true; + } + Event::Paste(paste) if view.ready && view.pending.is_none() => { + view.input.push_str(&paste.replace(['\r', '\n'], " ")); + dirty = true; + } + Event::Resize(width, height) => { + pty.resize(height, width)?; + terminal.clear().map_err(|e| e.to_string())?; + dirty = true; + } + _ => {} + } + } + } + if let Some((_, reply)) = view.pending.take() { + let _ = reply.send(false); + } + let code = status.unwrap_or(1); + store.finish(&mut state, id, code)?; + Ok((id, code)) +} + #[cfg(test)] mod tests { use super::*; @@ -502,6 +1041,21 @@ mod tests { } #[cfg(unix)] #[test] + fn approval_requires_the_full_input_to_fit_on_screen() { + assert!(approval_fits( + "Ferramenta: Bash\nEntrada completa:\n{\"command\":\"echo ok\"}", + 80, + 24 + )); + assert!(!approval_fits(&"x".repeat(3000), 80, 24)); + assert!(!approval_fits( + "Ferramenta: Bash\nEntrada completa:\n{\"command\":\"echo ok\"}", + 30, + 10 + )); + } + #[cfg(unix)] + #[test] fn fake_cli_runs_in_pty_and_echoes_input() { use std::{fs, os::unix::fs::PermissionsExt}; let root = std::env::temp_dir().join(format!("bee-pty-{}", Uuid::new_v4())); @@ -510,8 +1064,16 @@ mod tests { fs::write(&fake, "#!/bin/sh\nprintf 'READY\\r\\n'\nIFS= read -r line\nprintf 'ECHO:%s\\r\\n' \"$line\"\n").unwrap(); fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).unwrap(); let id = Uuid::new_v4(); - let mut pty = - Pty::start(fake.as_os_str(), &root, id, false, pty_size(10, 40), true).unwrap(); + let mut pty = Pty::start( + fake.as_os_str(), + &root, + id, + false, + pty_size(10, 40), + true, + None, + ) + .unwrap(); pty.write(b"hello\r").unwrap(); let mut parser = vt100::Parser::new(10, 40, 0); let until = Instant::now() + Duration::from_secs(3);