multiline paste and cursor fix
This commit is contained in:
28
CLAUDE.md
28
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
|
||||
|
||||
38
src/term.rs
38
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<Color> {
|
||||
match c {
|
||||
|
||||
48
src/ui.rs
48
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<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() {
|
||||
|
||||
Reference in New Issue
Block a user