multiline paste and cursor fix

This commit is contained in:
Jonas H
2026-06-16 11:45:26 +02:00
parent 10279a49a4
commit 8ffd258341
3 changed files with 105 additions and 9 deletions

View File

@@ -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<Color> {
match c {

View File

@@ -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<crate::term::CursorShape>,
}
/// 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<Selection> = 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<crate::term::CursorShape> = 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<crate::term::CursorShape> = 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() {