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:
1635
Cargo.lock
generated
1635
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
|
||||
76
src/app.rs
76
src/app.rs
@@ -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()));
|
||||
|
||||
@@ -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
276
src/term.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/ui.rs
145
src/ui.rs
@@ -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,9 +166,23 @@ 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)])
|
||||
// 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)])
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user