From 3e95985699e7c13fb6e2cb8fcc4db46b938d1830 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:39:13 +0000 Subject: [PATCH] feat: multiline editor and scrolling for the hidden Claude conversation (#32) A new Draft editor gives the Bee view multiline input with cursor movement; paste keeps line breaks and is never sent before Enter, and the draft survives permission reviews, export, the original screen and resizes. Multiline prompts reach Claude as one bracketed paste when it enabled that mode. The conversation wraps long lines, scrolls with PgUp/PgDn and returns to the end with Esc. No-color uses the terminal's default background, and shortcuts wrap onto up to four rows so all stay visible at the new 40x12 minimum. Refs #32 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- ROADMAP.md | 3 +- docs/EXECUTION.md | 31 +++- docs/MANUAL_TESTS.md | 18 +++ docs/specs/workspace.md | 17 ++- scripts/check_claude_hidden_pty.py | 82 +++++++++- src/tui/claude_native.rs | 230 ++++++++++++++++++++++++----- src/tui/draft.rs | 218 +++++++++++++++++++++++++++ src/tui/mod.rs | 1 + 8 files changed, 560 insertions(+), 40 deletions(-) create mode 100644 src/tui/draft.rs diff --git a/ROADMAP.md b/ROADMAP.md index 75bf21e..85abae8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,8 @@ por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas usa hooks para conversa e permissões na Bee e retoma o ID nativo. Mensagem real curta comprovada; prompts nativos sem hook são sinalizados e concluídos por `Ctrl+O` (#29); a origem das ações é explícita (#30); histórico e exportação - vêm da transcrição nativa (#31). Ferramentas/perfis reais exigem ensaio humano. + vêm da transcrição nativa (#31); editor multilinha e rolagem (#32). + Ferramentas/perfis reais 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 diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index 760efb3..9166fa2 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -81,7 +81,7 @@ Próximo: ensaio humano de ferramentas/permissões reais ([#30](https://github.com/lippdev/memory-bee/issues/30)) e histórico/exportação revisável ([#31](https://github.com/lippdev/memory-bee/issues/31)). O estado ainda guarda só IDs; histórico e exportação vêm da transcrição nativa -(#31, PR em andamento). Perfis reais e Codex integrado seguem pendentes. +(#31) e a conversa tem editor multilinha e rolagem (#32). 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) @@ -845,3 +845,32 @@ escopo da entrega atual nem a próxima tarefa aprovada. Exportação após o Claude encerrar é feita pela CLI `export`, não pela TUI. Código selecionado (v2) não é oferecido na TUI. - Próximo: ensaios humanos 52 e 54; depois Codex real no workspace (ADR 0018). + +## Claude oculto — editor e navegação da conversa (#32, código) + +- Data: 2026-09-28. PR #52 (#31) integrado por squash em `1871931`; branch da + sessão recomeçado da `main`. +- Novo `src/tui/draft.rs`: rascunho multilinha com cursor por caractere, + colagem que preserva quebras e descarta controles, e quebra por largura de + exibição (inclui caracteres largos). `workspace --claude` passa a usar esse + editor (`Alt+Enter`/`Ctrl+J`, setas, Home/End, Delete), mantém o rascunho em + permissões, exportação, tela original e resize, e envia mensagens de várias + linhas como colagem delimitada quando o Claude a ativou (sem ela, quebras viram + espaços). A conversa quebra linhas longas (antes cortadas), rola com + `PgUp`/`PgDn` e ↑/↓ nas bordas do rascunho, avisa novas mensagens abaixo e + volta ao fim com `Esc` ou ao enviar. Sem cor usa o fundo padrão do terminal. + Atalhos se distribuem em até quatro linhas; mínimo passou de 40×10 para 40×12 + para mantê-los todos visíveis. +- Evidências: testes unitários do editor (colunas entre linhas, colagem, + quebra com caracteres largos e posição do cursor); `check_claude_hidden_pty.py` + com CLI falsa que registra a entrada recebida: rolagem com `PgUp` e `Esc`, + colagem não enviada antes de Enter, edição com Home/End/`Alt+Enter`, envio + delimitado exato e envio em uma linha sem colagem delimitada, em 80×24 e 40×12. + Telas conferidas em emulador (`pyte`), o que revelou e corrigiu o cursor fora da + caixa em 40×12. Checks canônicos, scripts PTY e `check_bundle.py` passaram + localmente (Linux). Autorrevisão. +- Limitações: `Alt+Enter` depende do terminal; `Ctrl+J` é a alternativa. Ensaio + em terminais reais pendente (item 55). A rolagem é por mensagem, não por linha + quebrada. +- Próximo: #33 (Codex App Server autenticado), que também exige ensaio com conta + real, ou #41 (recuperação do estado após falha). diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index b77e98e..7727426 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -952,3 +952,21 @@ impresso. Não versionar pacotes com dados reais. | Item novo | Estado | Evidência manual | |---|---|---| | 54: reidratação na retomada, exportação com exclusões, segredos e verify | Pendente | — | + +## 55. Editor e navegação do Claude oculto (pendente) + +Em terminais reais (Terminal.app, iTerm2 e um Linux), abrir `workspace --claude` +num projeto descartável já confiado. Digitar uma mensagem, usar `Alt+Enter` (e +`Ctrl+J`, se `Alt+Enter` não chegar) para quebrar linha, mover com setas e +Home/End e corrigir com Backspace/Delete. Colar um texto de várias linhas: +nada deve ser enviado antes de Enter. Enviar e conferir no Claude original +(`Ctrl+O`) que a mensagem chegou inteira, com as quebras. Gerar uma resposta +longa, rolar com `PgUp`/`PgDn`, conferir o título "lendo anteriores"/"novas +abaixo" e voltar com `Esc`. Com rascunho digitado, provocar um pedido de +permissão e redimensionar a janela: o rascunho deve continuar. Repetir com +`--no-color` e em 40×12, conferindo todos os atalhos visíveis. Sair com `Ctrl+Q` +e após um erro do Claude, verificando a restauração do terminal. + +| Item novo | Estado | Evidência manual | +|---|---|---| +| 55: editor multilinha, colagem, rolagem, sem cor e 40×12 em terminais reais | Pendente | — | diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 2dafe64..4af1880 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -29,7 +29,8 @@ O Claude continua controlando login, sessão e execução. Um socket Unix em pas 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 +inicial. Enter envia prompt, `Ctrl+C` interrompe e `Ctrl+Q` encerra (editor e +rolagem descritos abaixo). 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 @@ -76,6 +77,20 @@ processo ativo antes de remover a trava antiga. `--resume` usa o último ID dest pasta; não reenvia prompt. A sessão nativa pode não existir quando se sai antes de enviar uma mensagem. Isolamento de perfis e integração Codex estão pendentes. +**Editor e navegação (#32).** O rascunho tem várias linhas: `Alt+Enter` (ou +`Ctrl+J`) quebra linha; setas, Home/End, Backspace e Delete editam; a colagem +entra no rascunho com as quebras preservadas e nunca é enviada sem Enter. O +rascunho continua ao abrir permissões, exportação, a tela original ou ao +redimensionar, e pode ser escrito antes de o Claude ficar pronto. Ao enviar, +mensagens com várias linhas vão ao Claude como uma única colagem delimitada +quando o terminal dele a ativou; sem isso, as quebras viram espaços para não +enviar pedaços. A conversa quebra linhas pela largura de exibição; `PgUp`/`PgDn` +(ou ↑/↓ fora do rascunho) leem mensagens anteriores, o título indica novas +mensagens abaixo e `Esc` ou enviar volta ao fim. A caixa de mensagem mostra até +cinco linhas sem esconder a conversa. Sem cor, a tela usa as cores padrão do +terminal. O tamanho mínimo é 40×12; os atalhos ocupam até quatro linhas para +continuarem legíveis nessa largura. + **Histórico e exportação (#31).** A transcrição vem do campo `transcript_path` dos hooks oficiais; a Bee aceita só arquivo comum, sem symlink, chamado `.jsonl`, e não guarda o caminho no estado. Na retomada, ao receber diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py index a51979e..320c0b1 100644 --- a/scripts/check_claude_hidden_pty.py +++ b/scripts/check_claude_hidden_pty.py @@ -80,6 +80,68 @@ def hook(name, **fields): sys.exit(0) ''' +# Editor checks: records exactly what Claude would receive on its input. +FAKE_EDITOR = r'''#!/usr/bin/env python3 +import json, os, signal, subprocess, sys +signal.signal(signal.SIGINT, signal.SIG_IGN) # Real Claude reads Ctrl+C as a key. +args = sys.argv[1:] +session_id = args[args.index('--session-id') + 1] +if os.environ.get('BEE_BRACKETED'): + sys.stdout.write('\x1b[?2004h') + sys.stdout.flush() +def hook(name, **fields): + payload = json.dumps(dict(hook_event_name=name, session_id=session_id, **fields)) + subprocess.run([os.environ['BEE_BINARY'], '__claude-hook'], input=payload, text=True, capture_output=True, check=True) +hook('SessionStart') +hook('MessageDisplay', delta='\n'.join(f'linha-{i:02d}' for i in range(60)), final=True, index=0) +for line in sys.stdin: + line = line.rstrip('\n') + with open(os.environ['BEE_FAKE_STDIN'], 'a') as f: + f.write(repr(line) + '\n') + if line == '/exit': + sys.exit(0) + if not line.startswith('\x1b[200~') and not (os.environ.get('BEE_BRACKETED') and '\x1b[201~' not in line): + hook('Stop') # The turn ends once the whole message arrived. +''' + + +def editor(binary, project, state, env, size, bracketed): + rows, cols = size + master, slave, before, proc, output, until = spawn(binary, project, state, env, size=size) + log = Path(env['BEE_FAKE_STDIN']) + try: + until(b'linha-59') + os.write(master, b'\x1b[5~') # PgUp reads older messages. + until(b'anteriores') + os.write(master, b'\x1b') # Esc follows the end again. + time.sleep(0.3) + os.write(master, b'\x1b[200~linha um\nlinha dois\x1b[201~') + time.sleep(0.5) + assert not log.exists(), 'paste must not be sent before Enter' + os.write(master, b'\x1b[H>') # Home, then edit the second line. + os.write(master, b'\x1b[F\x1b\r') # End, then Alt+Enter adds a line. + os.write(master, 'três'.encode()) + os.write(master, b'\r') + until(b'Turno') # Stop arrived: Ctrl+Q may type /exit at the idle prompt. + os.write(master, b'\x11') + code = drain_until_exit(master, proc, output, 10) + assert code == 0, (code, log.exists() and log.read_text()) + assert termios.tcgetattr(slave) == before + assert b'Ctrl+Q' in output, 'shortcuts stay visible at this width' + received = [eval(line) for line in log.read_text().splitlines()] + if bracketed: + expected = ['\x1b[200~linha um', '>linha dois', 'três\x1b[201~', '/exit'] + else: + expected = ['linha um >linha dois três', '/exit'] + assert received == expected, received + log.unlink() + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + def history_and_export(binary, project, state, env, resume): master, slave, before, proc, output, until = spawn(binary, project, state, env, resume) @@ -118,9 +180,9 @@ def drain_until_exit(master, proc, output, seconds): return proc.returncode -def spawn(binary, project, state, env, resume=False): +def spawn(binary, project, state, env, resume=False, size=(24, 80)): master, slave = pty.openpty() - fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', size[0], size[1], 0, 0)) before = termios.tcgetattr(slave) argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] if resume: @@ -203,7 +265,7 @@ def until(token): try: until(b'pronto.') os.write(master, b'hello\r') - until(b'Permitir esta') + until(b'Permitir') os.write(master, decision.encode()) until(b'sintetica') until(b'regras') # Read ran without a request: Claude's rules, not the Bee. @@ -291,7 +353,19 @@ def main(): history_and_export(binary, project, state, env, True) # History is shown from the transcript, never typed back into Claude. assert stdin_log.read_text().splitlines() == ['/exit', '/exit'] - print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q, history rehydration, verified export and terminal restoration') + with tempfile.TemporaryDirectory(prefix='bee-editor-') as temp: + root = Path(temp) + fake_dir = root / 'bin' + fake_dir.mkdir() + fake = fake_dir / 'claude' + fake.write_text(FAKE_EDITOR) + fake.chmod(0o700) + project = root / 'project' + project.mkdir() + env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', BEE_BINARY=str(binary), BEE_FAKE_STDIN=str(root / 'stdin')) + editor(binary, project, root / 'state-a', dict(env, BEE_BRACKETED='1'), (24, 80), True) + editor(binary, project, root / 'state-b', env, (12, 40), False) # Documented minimum width. + print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q, history rehydration, verified export, multiline editor, scrolling, 40-column controls and terminal restoration') if __name__ == '__main__': diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 99e5ed5..c7670f9 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -1,5 +1,8 @@ //! Memory Bee frame around the unmodified, interactive Claude Code CLI. -use super::theme::{Mode, Palette}; +use super::{ + draft::{Draft, wrap}, + theme::{Mode, Palette}, +}; use crossterm::{ cursor::Show, event::{ @@ -959,7 +962,11 @@ fn private_parent(destination: &Path) -> Result<(), String> { #[cfg(unix)] struct HiddenView { lines: Vec, - input: String, + draft: Draft, + /// Last visible line while reading older messages; `None` follows the end. + anchor: Option, + /// Lines that arrived while reading older messages. + unread: usize, pending: Option, /// Transcript reported by Claude's hooks; source of history and export. transcript: Option, @@ -988,7 +995,9 @@ impl HiddenView { fn new(now: Instant) -> Self { Self { lines: vec!["Iniciando Claude Code em segundo plano…".into()], - input: String::new(), + draft: Draft::default(), + anchor: None, + unread: 0, pending: None, transcript: None, resume: false, @@ -1008,10 +1017,29 @@ impl HiddenView { } fn push(&mut self, line: impl Into) { self.lines.push(line.into()); + if self.anchor.is_some() { + self.unread += 1; + } if self.lines.len() > 2000 { self.lines.drain(..1000); + self.anchor = self.anchor.map(|a| a.saturating_sub(1000)); + } + } + /// Moves the view by whole lines; reaching the end follows it again. + fn scroll(&mut self, lines: isize) { + let last = self.lines.len().saturating_sub(1); + let current = self.anchor.unwrap_or(last) as isize; + let target = (current + lines).clamp(0, last as isize) as usize; + if target >= last { + self.follow(); + } else { + self.anchor = Some(target); } } + fn follow(&mut self) { + self.anchor = None; + self.unread = 0; + } /// Detects missing progress. Returns true when the view changed. fn check_stall(&mut self, now: Instant, screen: impl FnOnce() -> String) -> bool { if self @@ -1327,22 +1355,72 @@ fn approval_fits(request: &str, width: u16, height: u16) -> bool { <= text_rows } +/// Shortcuts packed into as many rows as the width needs; four rows keep +/// every control readable at the 40×12 minimum. +#[cfg(unix)] +fn footer_rows(width: u16, view: &HiddenView) -> Vec { + let items: &[&str] = if view.export.is_some() { + &["Esc fecha a exportação"] + } else { + &[ + "Enter envia", + "Alt+Enter nova linha", + "PgUp/PgDn rolam", + "Ctrl+E exporta", + "Ctrl+C interrompe", + "Ctrl+O original", + "Ctrl+Q sai", + ] + }; + let mut rows = vec![String::new()]; + for item in items { + let row = rows.last_mut().expect("one row"); + if row.is_empty() { + row.push_str(item); + } else if UnicodeWidthStr::width(row.as_str()) + 3 + UnicodeWidthStr::width(*item) + > width as usize + { + rows.push((*item).into()); + } else { + row.push_str(" · "); + row.push_str(item); + } + } + 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 { + let bg = if no_color { + Color::Reset + } else 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 inner_width = area.width.saturating_sub(2); + let (draft_rows, draft_cursor) = view.draft.layout(inner_width.saturating_sub(2)); + let footer = footer_rows(area.width, view); + // Up to five draft rows, never squeezing the conversation below one line. + let room = area + .height + .saturating_sub(footer.len() as u16 + 1 + 3 + 2) + .max(1) as usize; + let draft_height = draft_rows.len().clamp(1, 5).min(room); + let input_height = if view.pending.is_none() && view.blocked.is_none() && !view.quitting { + draft_height as u16 + 2 + } else { + 3 + }; let rows = Layout::vertical([ Constraint::Length(1), Constraint::Min(3), - Constraint::Length(3), - Constraint::Length(1), + Constraint::Length(input_height), + Constraint::Length(footer.len() as u16), ]) .split(area); frame.render_widget( @@ -1354,33 +1432,75 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) ), rows[0], ); + // Wrapped by display width so long messages are never cut at the edge. let visible = rows[1].height.saturating_sub(2) as usize; - let start = view.lines.len().saturating_sub(visible); + let last = view + .anchor + .unwrap_or(usize::MAX) + .min(view.lines.len().saturating_sub(1)); + let mut shown: Vec = Vec::new(); + for line in view.lines[..=last].iter().rev() { + let mut wrapped = wrap(line, inner_width); + wrapped.extend(shown); + shown = wrapped; + if shown.len() >= visible { + break; + } + } + let skip = shown.len().saturating_sub(visible); + let title = match (view.anchor, view.unread) { + (None, _) => " Conversa ".to_string(), + (Some(_), 0) => " Conversa · lendo anteriores · Esc volta ao fim ".into(), + (Some(_), n) => format!(" Conversa · {n} novas abaixo · Esc volta ao fim "), + }; frame.render_widget( - Paragraph::new(view.lines[start..].join("\n")) + Paragraph::new(shown[skip..].join("\n")) .block( Block::default() - .title(" Conversa ") + .title(title) .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() + let (prompt, cursor) = if view.pending.is_some() { + ("Permissão pendente · rascunho preservado".to_string(), None) } else if let Some(hint) = view.blocked { - format!("Claude aguarda {hint} · Ctrl+O concluir no original · Ctrl+Q sair") - } else if !view.ready { - "Aguardando Claude iniciar…".into() + ( + format!("Claude aguarda {hint} · Ctrl+O concluir no original · Ctrl+Q sair"), + None, + ) + } else if view.quitting { + ("Encerrando…".to_string(), None) + } else if view.draft.text().is_empty() && !view.ready { + ( + "> (Claude iniciando; nada é enviado antes do Enter)".to_string(), + None, + ) } else { - format!("> {}", view.input) + // Keep the cursor row visible within the rows the layout allows. + let height = draft_height; + let first = draft_cursor + .0 + .saturating_sub(height - 1) + .min(draft_rows.len() - height); + let text = draft_rows[first..first + height] + .iter() + .enumerate() + .map(|(i, row)| { + let marker = if first + i == 0 { "> " } else { " " }; + format!("{marker}{row}") + }) + .collect::>() + .join("\n"); + (text, Some((draft_cursor.0 - first, draft_cursor.1))) }; frame.render_widget( Paragraph::new(prompt) .block( Block::default() - .title(" Mensagem / decisão ") + .title(" Mensagem ") .borders(Borders::ALL) .border_style(Style::default().fg(palette.accent)), ) @@ -1388,12 +1508,12 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) rows[2], ); frame.render_widget( - Paragraph::new( - "Enter envia · Ctrl+E exporta · Ctrl+C interrompe · Ctrl+O original · Ctrl+Q sai", - ) - .style(Style::default().bg(bg)), + Paragraph::new(footer.join("\n")).style(Style::default().bg(bg)), rows[3], ); + if let (Some((row, col)), None) = (cursor, &view.export) { + frame.set_cursor_position((rows[2].x + 3 + col, rows[2].y + 1 + row as u16)); + } if let (None, Some(export)) = (&view.pending, &view.export) { let modal = Rect::new( 2, @@ -1527,6 +1647,21 @@ fn draw_native_setup( frame.render_widget(Paragraph::new(footer), rows[2]); } +/// Types the prompt into Claude. Line breaks go as one bracketed paste so +/// Claude keeps them inside the message instead of submitting early. +#[cfg(unix)] +fn send_prompt(pty: &mut Pty, screen: &vt100::Screen, prompt: &str) -> Result<(), String> { + if prompt.contains('\n') && screen.bracketed_paste() { + pty.write(b"\x1b[200~")?; + pty.write(prompt.as_bytes())?; + pty.write(b"\x1b[201~")?; + } else { + // Without bracketed paste a newline would submit; keep one message. + pty.write(prompt.replace('\n', " ").as_bytes())?; + } + pty.write(b"\r") +} + /// First native Bee conversation surface. Claude's own terminal stays private. #[cfg(unix)] pub fn run_hidden( @@ -1551,8 +1686,8 @@ pub fn run_hidden( 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()); + if size.width < 40 || size.height < 12 { + return Err("Terminal exige pelo menos 40×12".into()); } // Claude draws its original screen inside the frame opened by Ctrl+O. let (rows, cols) = viewport(size.into()); @@ -1741,18 +1876,44 @@ pub fn run_hidden( .as_ref() .is_some_and(|p| approval_fits(&p.request, size.width, size.height)); view.decide(key.code == KeyCode::Char('y') && fits, false); - } else if view.pending.is_none() && view.ready && !view.quitting { + } else if view.pending.is_none() && !view.quitting { + let alt = key.modifiers.contains(KeyModifiers::ALT); + let page = (terminal.size().map_or(24, |s| s.height) / 2).max(1) as isize; match key.code { - KeyCode::Char(c) if !control => view.input.push(c), - KeyCode::Backspace => { - view.input.pop(); + KeyCode::Enter if alt => view.draft.insert('\n'), + KeyCode::Char('j') if control => view.draft.insert('\n'), + KeyCode::Char(c) if !control && !alt => view.draft.insert(c), + KeyCode::Backspace => view.draft.backspace(), + KeyCode::Delete => view.draft.delete(), + KeyCode::Left => view.draft.left(), + KeyCode::Right => view.draft.right(), + KeyCode::Home => view.draft.home(), + KeyCode::End => view.draft.end(), + KeyCode::Up => { + if !view.draft.up() { + view.scroll(-1); + } + } + KeyCode::Down => { + if !view.draft.down() { + view.scroll(1); + } } - 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")?; + KeyCode::PageUp => view.scroll(-page), + KeyCode::PageDown => view.scroll(page), + KeyCode::Esc => view.follow(), + KeyCode::Enter if view.ready && !view.draft.is_blank() => { + let prompt = view.draft.take(); + send_prompt(&mut pty, parser.screen(), &prompt)?; view.submitted(Instant::now()); - view.push(format!("Você: {prompt}")); + view.follow(); + for (i, line) in prompt.lines().enumerate() { + view.push(if i == 0 { + format!("Você: {line}") + } else { + format!(" {line}") + }); + } } _ => {} } @@ -1768,8 +1929,11 @@ pub fn run_hidden( pty.write(b"\x1b[201~")?; } } - Event::Paste(paste) if view.ready && view.pending.is_none() => { - view.input.push_str(&paste.replace(['\r', '\n'], " ")); + // Pasted text is only inserted in the draft; Enter sends it. + Event::Paste(paste) + if view.pending.is_none() && view.export.is_none() && !view.quitting => + { + view.draft.paste(&paste); dirty = true; } Event::Resize(width, height) => { diff --git a/src/tui/draft.rs b/src/tui/draft.rs new file mode 100644 index 0000000..cd577de --- /dev/null +++ b/src/tui/draft.rs @@ -0,0 +1,218 @@ +//! Multiline message draft with a cursor, independent of any terminal. +use unicode_width::UnicodeWidthChar; + +#[derive(Default)] +pub struct Draft { + text: String, + /// Byte offset, always on a char boundary. + cursor: usize, +} + +impl Draft { + pub fn text(&self) -> &str { + &self.text + } + pub fn is_blank(&self) -> bool { + self.text.trim().is_empty() + } + /// Returns the text and leaves an empty draft. + pub fn take(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.text) + } + pub fn insert(&mut self, c: char) { + let c = if c == '\t' { ' ' } else { c }; + if c == '\n' || !c.is_control() { + self.text.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + } + /// Pasted text keeps its line breaks; other control characters are dropped. + pub fn paste(&mut self, text: &str) { + for c in text.replace("\r\n", "\n").replace('\r', "\n").chars() { + self.insert(c); + } + } + pub fn backspace(&mut self) { + if let Some(c) = self.text[..self.cursor].chars().next_back() { + self.cursor -= c.len_utf8(); + self.text.remove(self.cursor); + } + } + pub fn delete(&mut self) { + if self.cursor < self.text.len() { + self.text.remove(self.cursor); + } + } + pub fn left(&mut self) { + if let Some(c) = self.text[..self.cursor].chars().next_back() { + self.cursor -= c.len_utf8(); + } + } + pub fn right(&mut self) { + if let Some(c) = self.text[self.cursor..].chars().next() { + self.cursor += c.len_utf8(); + } + } + fn line_start(&self) -> usize { + self.text[..self.cursor].rfind('\n').map_or(0, |i| i + 1) + } + fn line_end(&self) -> usize { + self.text[self.cursor..] + .find('\n') + .map_or(self.text.len(), |i| self.cursor + i) + } + pub fn home(&mut self) { + self.cursor = self.line_start(); + } + pub fn end(&mut self) { + self.cursor = self.line_end(); + } + /// Byte offset of the `column`-th char of the line starting at `start`. + fn at_column(&self, start: usize, column: usize) -> usize { + let line = &self.text[start..]; + let line = &line[..line.find('\n').unwrap_or(line.len())]; + start + + line + .char_indices() + .nth(column) + .map_or(line.len(), |(i, _)| i) + } + fn column(&self) -> usize { + self.text[self.line_start()..self.cursor].chars().count() + } + /// Returns false on the first line, so the caller can use Up elsewhere. + pub fn up(&mut self) -> bool { + let start = self.line_start(); + if start == 0 { + return false; + } + let column = self.column(); + let previous = self.text[..start - 1].rfind('\n').map_or(0, |i| i + 1); + self.cursor = self.at_column(previous, column); + true + } + /// Returns false on the last line. + pub fn down(&mut self) -> bool { + let end = self.line_end(); + if end == self.text.len() { + return false; + } + let column = self.column(); + self.cursor = self.at_column(end + 1, column); + true + } + /// Rows wrapped to `width` display cells, and the cursor as (row, col). + pub fn layout(&self, width: u16) -> (Vec, (usize, u16)) { + let width = width.max(1) as usize; + let mut rows = vec![String::new()]; + let mut used = 0; + let mut cursor = (0, 0); + for (i, c) in self.text.char_indices() { + if i == self.cursor { + cursor = (rows.len() - 1, used as u16); + } + if c == '\n' { + rows.push(String::new()); + used = 0; + continue; + } + let w = c.width().unwrap_or(0); + if used + w > width { + rows.push(String::new()); + used = 0; + if i == self.cursor { + cursor = (rows.len() - 1, 0); + } + } + rows.last_mut().expect("one row").push(c); + used += w; + } + if self.cursor == self.text.len() { + if used >= width { + rows.push(String::new()); + used = 0; + } + cursor = (rows.len() - 1, used as u16); + } + (rows, cursor) + } +} + +/// Splits a line into rows of at most `width` display cells. +pub fn wrap(line: &str, width: u16) -> Vec { + let width = width.max(1) as usize; + let mut rows = vec![String::new()]; + let mut used = 0; + for c in line.chars() { + let w = c.width().unwrap_or(0); + if used + w > width { + rows.push(String::new()); + used = 0; + } + rows.last_mut().expect("one row").push(c); + used += w; + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft(text: &str) -> Draft { + let mut d = Draft::default(); + d.paste(text); + d + } + + #[test] + fn edits_across_lines_keep_the_column() { + let mut d = draft("primeira\nb\nterceira"); + assert!(d.up()); + assert_eq!(d.layout(80).1, (1, 1)); + assert!(d.up()); + assert_eq!(d.layout(80).1, (0, 1), "column follows the short line"); + assert!(!d.up()); + d.home(); + d.insert('>'); + d.end(); + d.backspace(); + assert!(d.down() && d.down()); + assert!(!d.down()); + d.left(); + d.delete(); + assert_eq!(d.text(), ">primeir\nb\nerceira"); + } + + #[test] + fn paste_normalizes_breaks_and_drops_controls() { + let mut d = draft("a\r\nb\rc\x1b[31m\td"); + assert_eq!(d.text(), "a\nb\nc[31m d"); + assert!(!d.is_blank()); + assert_eq!(d.take(), "a\nb\nc[31m d"); + assert!(d.is_blank() && d.text().is_empty()); + d.paste(" \n "); + assert!(d.is_blank()); + } + + #[test] + fn layout_wraps_wide_chars_and_places_the_cursor() { + let mut d = draft("abcdé漢字"); + let (rows, cursor) = d.layout(4); + assert_eq!(rows, ["abcd", "é漢", "字"]); + assert_eq!(cursor, (2, 2)); + d.home(); + d.right(); + d.right(); + d.right(); + d.right(); + assert_eq!(d.layout(4).1, (1, 0)); + assert_eq!( + draft("abcd").layout(4), + (vec!["abcd".into(), String::new()], (1, 0)) + ); + assert_eq!(wrap("漢字漢", 3), ["漢", "字", "漢"]); + assert_eq!(wrap("", 3), [""]); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 843996e..501189c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2,6 +2,7 @@ pub mod actions; pub mod app; pub mod claude_native; +pub mod draft; pub mod theme; pub mod ui;