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;