Add embedded claude pane (option B spike): PTY + wezterm-term module

- src/term.rs: self-contained module spawning `claude --session-id <uuid>`
  with ANTHROPIC_BASE_URL pointed at our proxy, inside a portable-pty;
  wezterm-term models the screen (and answers terminal queries), a small
  renderer paints cells into the ratatui buffer.
- Toggle with alt-c; off by default, hiding keeps the session alive.
- Modifier-split input: plain keys go to claude, alt-keys drive the feed
  (alt-q quit, alt-j/k scroll, alt-f filter, alt-n/p session).
- Dynamic pane height: the tap flags AskUserQuestion/ExitPlanMode in the
  embedded session (matched via --session-id) before Claude Code renders
  the prompt → pane grows 35%→60%; shrinks when the tool_result echoes
  back in the next request.
- wezterm-term isn't on crates.io: pinned git rev of the monorepo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonas H
2026-06-11 08:16:53 +02:00
parent 3f49e11b21
commit 60df921dbb
6 changed files with 2096 additions and 49 deletions

1635
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,3 +13,11 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
tui-markdown = "=0.3.5"
# Embedded terminal module (src/term.rs): wezterm's terminal model is not
# published on crates.io, so we pin a rev of the monorepo. portable-pty is
# from the same project.
portable-pty = "0.9"
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
wezterm-surface = { git = "https://github.com/wezterm/wezterm", rev = "891bed31b75f7a71b78e8f42ad07ae89bf99a7de" }
uuid = { version = "1", features = ["v4"] }

View File

@@ -20,6 +20,18 @@ pub struct App {
pub filter_popup: Option<usize>,
/// Whether the session list panel is expanded.
pub show_sessions: bool,
/// Session UUID of the embedded `claude` pane (src/term.rs), so the tap
/// can recognise traffic belonging to it.
pub embed_session: Option<String>,
/// True while the embedded session shows a large interactive prompt
/// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet
/// echoed back) — the UI gives the pane more rows while set.
pub embed_grow: bool,
}
/// Client-side tools that render a large interactive UI in Claude Code.
fn is_interactive_tool(name: &str) -> bool {
matches!(name, "AskUserQuestion" | "ExitPlanMode")
}
impl App {
@@ -33,6 +45,8 @@ impl App {
filters: [true; FILTER_LABELS.len()],
filter_popup: None,
show_sessions: true,
embed_session: None,
embed_grow: false,
}
}
}
@@ -96,6 +110,10 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
return;
};
let mut a = app.lock().unwrap();
let is_embed = a.embed_session.as_deref() == Some(key);
// Set when the answer to an interactive prompt comes back: the embedded
// pane's prompt is gone, so the extra rows can be released.
let mut answered = false;
let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else {
return;
};
@@ -114,6 +132,9 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
continue;
};
if let Some(e) = s.entries.get_mut(idx) {
if let Kind::Tool { name } = &e.kind {
answered |= is_interactive_tool(name);
}
e.result = Some(ToolResult {
content: flatten_result_content(b.get("content")),
is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false),
@@ -121,6 +142,9 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
}
}
}
if is_embed && answered {
a.embed_grow = false;
}
}
/// tool_result content is either a plain string or an array of content blocks.
@@ -180,6 +204,9 @@ impl Tap {
pub fn handle(&mut self, ev: &str, d: &Value) {
let mut a = self.app.lock().unwrap();
// Set when a completed block means the embedded pane is about to show
// a big interactive prompt (checked against embed_session below).
let mut interactive = false;
let s = &mut a.sessions[self.sidx];
s.last_activity = Instant::now();
match ev {
@@ -256,7 +283,8 @@ impl Tap {
let e = &mut s.entries[i];
e.done = true;
// Tool input arrives as JSON fragments; pretty-print once complete.
if matches!(e.kind, Kind::Tool { .. }) {
if let Kind::Tool { name } = &e.kind {
interactive = is_interactive_tool(name);
if let Ok(v) = serde_json::from_str::<Value>(&e.content) {
if let Ok(p) = serde_json::to_string_pretty(&v) {
e.content = p;
@@ -291,6 +319,14 @@ impl Tap {
}
_ => {}
}
// The embedded pane is about to render an interactive prompt
// (wire-order guarantees this fires before Claude Code draws it):
// ask the UI for more rows.
if interactive
&& a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str())
{
a.embed_grow = true;
}
}
}
@@ -353,6 +389,44 @@ mod tests {
assert!(a.sessions[0].tool_ids.is_empty(), "id consumed");
}
#[test]
fn interactive_tool_toggles_embed_grow() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
app.lock().unwrap().embed_session = Some("emb".into());
let mut tap = Tap::new(app.clone(), "emb".into(), "claude-x".into());
tap.handle(
"content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}),
);
assert!(!app.lock().unwrap().embed_grow, "not before the block completes");
tap.handle("content_block_stop", &json!({}));
assert!(app.lock().unwrap().embed_grow, "grow once the prompt is imminent");
drop(tap);
// The user's answer comes back in the next request body → shrink.
attach_tool_results(
&app,
"emb",
&json!({"messages": [{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_q", "content": "picked A"}
]}]}),
);
assert!(!app.lock().unwrap().embed_grow);
}
#[test]
fn non_embed_session_never_grows() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
app.lock().unwrap().embed_session = Some("emb".into());
let mut tap = Tap::new(app.clone(), "other".into(), "claude-x".into());
tap.handle(
"content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_q", "name": "AskUserQuestion"}}),
);
tap.handle("content_block_stop", &json!({}));
assert!(!app.lock().unwrap().embed_grow);
}
#[test]
fn unknown_tool_id_is_ignored() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));

View File

@@ -1,6 +1,7 @@
mod app;
mod proxy;
mod sse;
mod term;
mod ui;
use app::App;
@@ -31,7 +32,7 @@ fn main() -> anyhow::Result<()> {
rt.block_on(proxy_handle)?;
Ok(())
} else {
let r = ui::run(app);
let r = ui::run(app, port);
rt.shutdown_background();
r
}

276
src/term.rs Normal file
View File

@@ -0,0 +1,276 @@
//! Embedded terminal module: runs `claude` inside a PTY and renders it as a
//! pane in our TUI. Self-contained — all portable-pty / wezterm-term usage
//! lives here so the feature can be toggled (or removed) without touching the
//! proxy/tap pipeline.
//!
//! Data flow: a reader thread pumps PTY output into `wezterm_term::Terminal`
//! (a full terminal model that also *answers* terminal queries by writing back
//! through the PTY writer — important for Ink-based Claude Code). The UI
//! thread locks the model each frame to paint cells, and forwards keystrokes
//! via `key_down`, which encodes them respecting whatever modes the child has
//! configured (application cursor keys, kitty keyboard, bracketed paste…).
use anyhow::Context;
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use wezterm_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{
Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline,
};
#[derive(Debug)]
struct Config;
impl TerminalConfiguration for Config {
fn color_palette(&self) -> ColorPalette {
ColorPalette::default()
}
// Claude Code can use the kitty keyboard protocol (shift+enter etc.);
// wezterm-term implements the encoding, so let the child enable it.
fn enable_kitty_keyboard(&self) -> bool {
true
}
}
pub struct EmbeddedTerm {
term: Arc<Mutex<Terminal>>,
master: Box<dyn MasterPty + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
exited: Arc<AtomicBool>,
/// Session UUID passed to `claude --session-id`; lets the tap recognise
/// which proxied session belongs to this pane.
pub session_id: String,
rows: u16,
cols: u16,
}
impl EmbeddedTerm {
/// Spawn `claude` in a fresh PTY, routed through our proxy.
pub fn spawn(port: u16, rows: u16, cols: u16) -> anyhow::Result<Self> {
let session_id = uuid::Uuid::new_v4().to_string();
let mut cmd = CommandBuilder::new("claude");
cmd.args(["--session-id", &session_id]);
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
Self::spawn_cmd(cmd, session_id, rows, cols)
}
fn spawn_cmd(
cmd: CommandBuilder,
session_id: String,
rows: u16,
cols: u16,
) -> anyhow::Result<Self> {
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.context("openpty")?;
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
let killer = child.clone_killer();
drop(pty.slave);
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
// key encodings back to the child through this writer.
let writer = pty.master.take_writer().context("pty writer")?;
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
cols: cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-thinking",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = pty.master.try_clone_reader().context("pty reader")?;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut child = child;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
let _ = child.wait();
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self { term, master: pty.master, killer, exited, session_id, rows, cols })
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
/// Resize PTY + terminal model to the pane's inner size (no-op if unchanged).
pub fn resize(&mut self, rows: u16, cols: u16) {
if (rows, cols) == (self.rows, self.cols) || rows == 0 || cols == 0 {
return;
}
self.rows = rows;
self.cols = cols;
let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
self.term.lock().unwrap().resize(TerminalSize {
rows: rows as usize,
cols: cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
});
}
/// 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;
use ratatui::crossterm::event::KeyModifiers as CM;
let key = match k.code {
CK::Char(c) => KeyCode::Char(c),
CK::Enter => KeyCode::Enter,
CK::Backspace => KeyCode::Backspace,
CK::Tab => KeyCode::Tab,
CK::BackTab => KeyCode::Tab, // SHIFT carried via modifiers
CK::Esc => KeyCode::Escape,
CK::Left => KeyCode::LeftArrow,
CK::Right => KeyCode::RightArrow,
CK::Up => KeyCode::UpArrow,
CK::Down => KeyCode::DownArrow,
CK::Home => KeyCode::Home,
CK::End => KeyCode::End,
CK::PageUp => KeyCode::PageUp,
CK::PageDown => KeyCode::PageDown,
CK::Delete => KeyCode::Delete,
CK::Insert => KeyCode::Insert,
CK::F(n) => KeyCode::Function(n),
_ => return false,
};
let mut mods = KeyModifiers::NONE;
if k.modifiers.contains(CM::SHIFT) || k.code == CK::BackTab {
mods |= KeyModifiers::SHIFT;
}
if k.modifiers.contains(CM::CONTROL) {
mods |= KeyModifiers::CTRL;
}
if k.modifiers.contains(CM::ALT) {
mods |= KeyModifiers::ALT;
}
self.term.lock().unwrap().key_down(key, mods).is_ok()
}
/// Paint the visible screen into `area`. Returns the cursor position
/// (absolute buffer coordinates) when the child wants it shown.
pub fn render(&self, area: Rect, buf: &mut Buffer) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
for (y, line) in lines.iter().enumerate() {
if y as u16 >= area.height {
break;
}
for cell in line.visible_cells() {
let x = cell.cell_index() as u16;
if x >= area.width {
break;
}
let attrs = cell.attrs();
let dst = &mut buf[(area.x + x, area.y + y as u16)];
dst.set_symbol(cell.str());
if let Some(c) = conv_color(attrs.foreground()) {
dst.set_fg(c);
}
if let Some(c) = conv_color(attrs.background()) {
dst.set_bg(c);
}
let mut m = Modifier::empty();
if attrs.intensity() == Intensity::Bold {
m |= Modifier::BOLD;
}
if attrs.intensity() == Intensity::Half {
m |= Modifier::DIM;
}
if attrs.italic() {
m |= Modifier::ITALIC;
}
if attrs.underline() != Underline::None {
m |= Modifier::UNDERLINED;
}
if attrs.reverse() {
m |= Modifier::REVERSED;
}
if attrs.strikethrough() {
m |= Modifier::CROSSED_OUT;
}
dst.set_style(ratatui::style::Style::new().add_modifier(m));
}
}
let cursor = term.cursor_pos();
(cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& (cursor.y as u16) < area.height)
.then(|| (area.x + cursor.x as u16, area.y + cursor.y as u16))
}
}
impl Drop for EmbeddedTerm {
fn drop(&mut self) {
let _ = self.killer.kill();
}
}
/// termwiz color → ratatui color. `Default` maps to None (keep pane default).
fn conv_color(c: ColorAttribute) -> Option<Color> {
match c {
ColorAttribute::Default => None,
ColorAttribute::PaletteIndex(i) => Some(Color::Indexed(i)),
ColorAttribute::TrueColorWithDefaultFallback(c)
| ColorAttribute::TrueColorWithPaletteFallback(c, _) => {
let (r, g, b, _) = c.to_srgb_u8();
Some(Color::Rgb(r, g, b))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test]
fn pty_output_reaches_rendered_buffer() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "printf 'hello-embed'; sleep 1"]);
let area = Rect::new(0, 0, 40, 5);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), 5, 40).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf);
let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
if row.contains("hello-embed") {
break;
}
assert!(Instant::now() < deadline, "never rendered output: {row:?}");
std::thread::sleep(Duration::from_millis(50));
}
}
}

147
src/ui.rs
View File

@@ -1,4 +1,5 @@
use crate::app::{filter_index, fmt_tokens, Kind, SharedApp, ToolResult, FILTER_LABELS};
use crate::term::EmbeddedTerm;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
@@ -8,26 +9,104 @@ use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}
use ratatui::Frame;
use std::time::Duration;
pub fn run(app: SharedApp) -> anyhow::Result<()> {
/// Embedded `claude` pane state (UI-thread only; the shared App carries just
/// the session id + grow flag so the proxy tap can talk to it).
struct EmbedUi {
term: Option<EmbeddedTerm>,
visible: bool,
port: u16,
}
impl EmbedUi {
/// Pane is visible and the child is still running → it owns plain keys.
fn focused(&self) -> bool {
self.visible && self.term.as_ref().is_some_and(|t| !t.exited())
}
}
pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
let mut terminal = ratatui::init();
let res = event_loop(&mut terminal, app);
let mut eui = EmbedUi { term: None, visible: false, port };
let res = event_loop(&mut terminal, app, &mut eui);
ratatui::restore();
res
}
fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: SharedApp) -> anyhow::Result<()> {
fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) {
if eui.visible {
eui.visible = false;
// A dead child is dropped on hide so the next toggle respawns.
if eui.term.as_ref().is_some_and(|t| t.exited()) {
eui.term = None;
app.lock().unwrap().embed_session = None;
}
return;
}
if eui.term.as_ref().is_some_and(|t| t.exited()) {
eui.term = None;
}
if eui.term.is_none() {
// Real dimensions are applied on the first draw via resize().
match EmbeddedTerm::spawn(eui.port, 20, 80) {
Ok(t) => {
let mut a = app.lock().unwrap();
a.embed_session = Some(t.session_id.clone());
a.embed_grow = false;
drop(a);
eui.term = Some(t);
}
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
}
eui.visible = true;
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: SharedApp,
eui: &mut EmbedUi,
) -> anyhow::Result<()> {
loop {
terminal.draw(|f| draw(f, &app))?;
terminal.draw(|f| draw(f, &app, eui))?;
if !event::poll(Duration::from_millis(33))? {
continue;
}
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Release {
continue;
}
// Alt-c toggles the embedded claude pane in every state.
if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::ALT) {
if k.kind == KeyEventKind::Press {
toggle_embed(eui, &app);
}
continue;
}
let embed_focused = eui.focused();
// Modifier-split routing: while the pane is focused, plain keys
// (incl. ctrl-c!) belong to claude; ALT-keys control the feed.
// The filter popup is modal and keeps its plain keys.
if embed_focused
&& !k.modifiers.contains(KeyModifiers::ALT)
&& app.lock().unwrap().filter_popup.is_none()
{
if let Some(et) = &eui.term {
et.key(k);
}
continue;
}
if k.kind != KeyEventKind::Press {
continue;
}
let mut a = app.lock().unwrap();
let nsess = a.sessions.len();
if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
if !embed_focused
&& k.code == KeyCode::Char('c')
&& k.modifiers.contains(KeyModifiers::CONTROL)
{
return Ok(());
}
// Filter popup captures input while open.
@@ -50,11 +129,13 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: SharedApp) -> anyhow
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('f') => a.filter_popup = Some(0),
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
KeyCode::Tab if nsess > 0 => {
// n/p mirror tab/backtab: alt-tab is usually grabbed by the
// window manager, so the embed-focused state needs them.
KeyCode::Tab | KeyCode::Char('n') if nsess > 0 => {
a.selected = (a.selected + 1) % nsess;
a.follow = true;
}
KeyCode::BackTab if nsess > 0 => {
KeyCode::BackTab | KeyCode::Char('p') if nsess > 0 => {
a.selected = (a.selected + nsess - 1) % nsess;
a.follow = true;
}
@@ -85,10 +166,24 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: SharedApp) -> anyhow
}
}
fn draw(f: &mut Frame, app: &SharedApp) {
fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) {
let mut a = app.lock().unwrap();
let [main, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
.areas(f.area());
// Embedded pane height: nothing when hidden; ~35% of the screen normally,
// ~60% while the tap says an interactive prompt is on screen.
let show_embed = eui.visible && eui.term.is_some();
let embed_h = if show_embed {
let total = f.area().height;
let pct = if a.embed_grow { 60 } else { 35 };
((total as u32 * pct / 100) as u16).clamp(10, total.saturating_sub(10))
} else {
0
};
let [main, embed_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(embed_h),
Constraint::Length(1),
])
.areas(f.area());
let left_width = if a.show_sessions { 26 } else { 0 };
let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)])
.areas(main);
@@ -228,10 +323,40 @@ fn draw(f: &mut Frame, app: &SharedApp) {
a.scroll = new_scroll;
a.follow = new_follow;
// Embedded claude pane
if show_embed {
let et = eui.term.as_mut().unwrap();
let exited = et.exited();
let id: String = et.session_id.chars().take(8).collect();
let title = if exited {
format!(" claude · {id} · exited ")
} else {
format!(" claude · {id} ")
};
let block = Block::bordered().title(title);
let inner = block.inner(embed_area);
f.render_widget(block, embed_area);
if exited {
f.render_widget(
Paragraph::new("\n claude exited — alt-c to close this pane")
.dark_gray(),
inner,
);
} else {
et.resize(inner.height, inner.width);
if let Some(pos) = et.render(inner, f.buffer_mut()) {
f.set_cursor_position(pos);
}
}
}
let embed_focused = eui.focused();
let keys = if a.filter_popup.is_some() {
"space toggle · j/k move · f/esc close"
} else if embed_focused {
"alt-c hide claude · alt-q quit · alt-j/k scroll · alt-f filter · alt-n/p session"
} else {
"q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom"
"q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom · alt-c claude"
};
f.render_widget(
Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()),