diff --git a/CLAUDE.md b/CLAUDE.md index 21e5597..2b442c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,24 @@ UI thread redraws on its own tick (no channel; just the mutex). - Mouse is captured: wheel always scrolls the feed (regardless of focus), and left-drag selects screen text, copied on release via OSC 52 (like Claude Code). Native terminal selection therefore needs shift held. +- Bracketed paste is enabled on the outer terminal (`EnableBracketedPaste`): + a multiline paste arrives as one `Event::Paste` and, when the claude pane + has focus, is handed to the child via `EmbeddedTerm::paste` + (wezterm-term's `send_paste` re-wraps it in bracketed markers iff the child + enabled them) — so Claude Code inserts it as one block instead of + submitting on the first embedded newline. Paste is ignored when the pane is + unfocused (nothing else takes text input). +- Pane cursor shape mirrors the child: each frame `draw` records the child's + DECSCUSR shape (`EmbeddedTerm::cursor_shape`) into `EmbedUi::cursor_shape` + and the event loop emits `SetCursorStyle` only on change (so a blinking + cursor isn't reset every frame), resetting to `DefaultUserShape` when no + pane cursor is shown / on teardown. Without this the outer terminal kept a + stale block cursor regardless of Claude Code's insert-vs-vim-normal state. + `term::cursor_style` maps the child's DECSCUSR `Default` to a blinking *bar*, + not `DefaultUserShape`: Claude Code's normal input leaves the cursor at the + terminal default expecting a bar caret, so forwarding the outer terminal's + own default (often a block) would wrongly show a block in insert mode; vim + normal mode still sends an explicit `SteadyBlock`. - ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count` (used to compute cached per-entry wrapped heights for follow/auto-scroll). - reqwest is `default-features = false` + `rustls-tls,stream` — don't enable @@ -170,11 +188,11 @@ UI thread redraws on its own tick (no channel; just the mutex). - Tool results only appear once the *next* request fires; if the session ends right after a tool call, that result is never seen. Output is what Claude Code sends the model (i.e. post-truncation). -- Embedded pane: no bracketed paste or mouse forwarding yet; no scrollback - view (live screen only); shift+enter needs kitty keyboard protocol pushed - on the outer terminal (not done); permission prompts aren't detected for - pane growth (not visible in the API stream — would need a Notification - hook hitting a local control endpoint). +- Embedded pane: no mouse forwarding yet; no scrollback view (live screen + only); shift+enter needs kitty keyboard protocol pushed on the outer + terminal (not done); permission prompts aren't detected for pane growth + (not visible in the API stream — would need a Notification hook hitting a + local control endpoint). - Materialized branch files satisfy our own parser (round-trip tested) but Claude Code's loader tolerance is only verified empirically by resuming one — if a CC update changes the JSONL schema, retest `b` + ctrl-↓. The diff --git a/src/term.rs b/src/term.rs index 7101721..02a4215 100644 --- a/src/term.rs +++ b/src/term.rs @@ -13,11 +13,13 @@ use anyhow::Context; use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; use ratatui::buffer::Buffer; +use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier}; use std::io::Read; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +pub use wezterm_surface::CursorShape; use wezterm_surface::CursorVisibility; use wezterm_term::color::{ColorAttribute, ColorPalette}; use wezterm_term::{ @@ -189,6 +191,21 @@ impl EmbeddedTerm { .key_down(KeyCode::Char('l'), KeyModifiers::CTRL); } + /// Forward pasted text to the child. wezterm-term wraps it in the + /// bracketed-paste markers (ESC[200~ … ESC[201~) iff the child enabled + /// bracketed paste — so Claude Code's input box treats a multiline paste + /// as one insert instead of submitting on the first embedded newline. + pub fn paste(&self, text: &str) { + let _ = self.term.lock().unwrap().send_paste(text); + } + + /// The child's current cursor shape (set via DECSCUSR). We mirror it onto + /// the outer terminal so the pane shows a bar in insert mode and a block + /// only when Claude Code's vim normal mode asks for one. + pub fn cursor_shape(&self) -> CursorShape { + self.term.lock().unwrap().cursor_pos().shape + } + /// Forward a key press to the child. Returns false for keys we don't map. pub fn key(&self, k: ratatui::crossterm::event::KeyEvent) -> bool { use ratatui::crossterm::event::KeyCode as CK; @@ -318,6 +335,27 @@ impl Drop for EmbeddedTerm { } } +/// Map the child's DECSCUSR cursor shape onto a crossterm cursor style so the +/// outer terminal renders the same shape over the pane. +/// +/// `Default` maps to a blinking bar, not `DefaultUserShape`: Claude Code's +/// normal (non-vim) input leaves the cursor at the terminal default and relies +/// on that default being a bar caret. Forwarding `DefaultUserShape` would +/// instead pick up the *outer* terminal's default (often a block), so the pane +/// would show a block in insert mode. Vim normal mode still gets its explicit +/// `SteadyBlock`. +pub fn cursor_style(shape: CursorShape) -> SetCursorStyle { + match shape { + CursorShape::BlinkingBlock => SetCursorStyle::BlinkingBlock, + CursorShape::SteadyBlock => SetCursorStyle::SteadyBlock, + CursorShape::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore, + CursorShape::SteadyUnderline => SetCursorStyle::SteadyUnderScore, + CursorShape::BlinkingBar => SetCursorStyle::BlinkingBar, + CursorShape::SteadyBar => SetCursorStyle::SteadyBar, + CursorShape::Default => SetCursorStyle::BlinkingBar, + } +} + /// termwiz color → ratatui color. `Default` maps to None (keep pane default). fn conv_color(c: ColorAttribute) -> Option { match c { diff --git a/src/ui.rs b/src/ui.rs index c34e160..b5d1f74 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,9 +2,10 @@ use crate::app::{ filter_index, fmt_tokens, Entry, Kind, Session, SharedApp, ToolResult, FILTER_LABELS, }; use crate::term::EmbeddedTerm; +use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, - MouseButton, MouseEventKind, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Position, Rect}; @@ -40,6 +41,11 @@ struct EmbedUi { /// same session within a few seconds forces the resume (the session may /// have ended long ago — liveness of external instances is unknowable). force_resume: Option<(String, Instant)>, + /// Cursor shape the embedded pane wants this frame (set by `draw` from the + /// child's DECSCUSR state); `None` when no pane cursor is shown. The event + /// loop diffs it and emits `SetCursorStyle` only on change, so the outer + /// terminal mirrors the child (bar in insert mode, block in vim normal). + cursor_shape: Option, } /// Mouse text selection over the whole screen (mimics Claude Code: drag to @@ -196,7 +202,10 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { let mut terminal = ratatui::init(); // Mouse capture: wheel events scroll the feed (regardless of pane focus). // Trade-off: text selection in the outer terminal now needs shift held. - let _ = execute!(std::io::stdout(), EnableMouseCapture); + // Bracketed paste: a multiline paste arrives as one `Event::Paste` we hand + // to the child as a bracketed paste, instead of N keystrokes whose first + // newline would submit the prompt. + let _ = execute!(std::io::stdout(), EnableMouseCapture, EnableBracketedPaste); let mut eui = EmbedUi { term: None, visible: false, @@ -205,9 +214,15 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { port, past_embeds: HashSet::new(), force_resume: None, + cursor_shape: None, }; let res = event_loop(&mut terminal, app, &mut eui); - let _ = execute!(std::io::stdout(), DisableMouseCapture); + let _ = execute!( + std::io::stdout(), + SetCursorStyle::DefaultUserShape, + DisableBracketedPaste, + DisableMouseCapture + ); ratatui::restore(); res } @@ -439,8 +454,18 @@ fn event_loop( ) -> anyhow::Result<()> { let mut sel: Option = None; let mut cache = FeedCache::default(); + // Last cursor shape pushed to the outer terminal; re-emit only on change so + // a blinking cursor isn't reset to its non-blinking phase every frame. + let mut applied_cursor: Option = None; loop { terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut cache))?; + if eui.cursor_shape != applied_cursor { + applied_cursor = eui.cursor_shape; + let style = eui + .cursor_shape + .map_or(SetCursorStyle::DefaultUserShape, crate::term::cursor_style); + let _ = execute!(std::io::stdout(), style); + } // Scheduled transcript wipe (set by the tap when an embedded-session // turn finishes): keeps the pane prompt-only. let clear_due = { @@ -462,6 +487,17 @@ fn event_loop( continue; } let ev = event::read()?; + // Bracketed paste: forward the whole blob to the child as one paste + // when the pane has focus (no submit on embedded newlines). Nowhere + // else accepts text input, so ignore it otherwise. + if let Event::Paste(text) = &ev { + if eui.focused() + && let Some(et) = &eui.term + { + et.paste(text); + } + continue; + } // Wheel scroll always drives the feed, regardless of which pane has // keyboard focus (the embedded pane gets no mouse forwarding anyway). // Left drag = text selection; the copy happens on release in draw(). @@ -1073,6 +1109,8 @@ fn draw( // Embedded claude pane let embed_focused = eui.focused(); + // Cursor shape the pane wants this frame (None unless it draws a cursor). + let mut want_cursor: Option = None; if show_embed { let et = eui.term.as_mut().unwrap(); let exited = et.exited(); @@ -1100,9 +1138,11 @@ fn draw( et.resize(inner.height, inner.width, crop); if let Some(pos) = et.render(inner, f.buffer_mut(), crop) { f.set_cursor_position(pos); + want_cursor = Some(et.cursor_shape()); } } } + eui.cursor_shape = want_cursor; let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some()); let keys = if a.filter_popup.is_some() {