Files
claude-cloak/src/ui.rs
2026-06-17 12:53:18 +02:00

1856 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use serde_json::Value;
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use std::collections::HashSet;
use std::time::{Duration, Instant};
/// 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).
///
/// One app instance hosts at most one embedded claude (`term`). The pane is
/// drawn only while the feed selection is on its session: tabbing away hides
/// it without killing the child, so tabbing back is instant. ctrl-↓ is the
/// commit point that may kill + respawn (`attach_selected`).
struct EmbedUi {
term: Option<EmbeddedTerm>,
visible: bool,
/// Keyboard focus is on the claude pane (vs. the feed above it).
/// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed.
claude_focused: bool,
/// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the
/// pane has focus; cleared when focus leaves it or it is hidden.
fullscreen: bool,
port: u16,
/// Sessions that were embedded earlier in this process: their instances
/// are known dead (we killed them), so resuming needs no liveness guard.
past_embeds: HashSet<String>,
/// Armed by ctrl-↓ on a live external session: pressing again on the
/// 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
/// select with a reversed-video highlight, the underlying screen text is
/// copied to the system clipboard on release via OSC 52).
struct Selection {
start: (u16, u16), // (x, y) anchor cell
end: (u16, u16), // (x, y) current cell (inclusive)
dragging: bool,
/// Set on mouse release: the next draw extracts + copies the text.
copy_pending: bool,
}
impl EmbedUi {
/// Pane is visible, child alive, and holds keyboard focus → it gets keys.
fn focused(&self) -> bool {
self.visible
&& self.claude_focused
&& self.term.as_ref().is_some_and(|t| !t.exited())
}
}
/// Per-entry feed render cache. Entries are append-only and an entry's
/// content only changes by streaming appends / tool-result attachment, so the
/// rendered lines + wrapped height are cached per entry and rebuilt only when
/// the fingerprint changes. Two effects: the per-frame work done *while
/// holding the app mutex* is proportional to what changed, not to session
/// length (the proxy tap shares that mutex — a slow render must not starve
/// it), and the feed scrolls correctly past u16::MAX wrapped lines because
/// only the visible window of lines is handed to ratatui.
#[derive(Default)]
struct FeedCache {
session_key: String,
width: u16,
/// Tree path (leaf turn) the cached view renders; None = whole file/live.
leaf: Option<usize>,
/// Live in-memory session vs on-disk transcript (same uuid, different
/// entry lists — must not share cache slots).
live: bool,
entries: Vec<CachedEntry>,
}
struct CachedEntry {
fingerprint: (usize, bool, usize, bool, bool),
lines: Vec<Line<'static>>,
/// Rows after wrapping to `FeedCache::width` (incl. trailing separator).
height: usize,
}
/// Cheap change-detector for a cached entry: content only ever grows (or is
/// swapped for the pretty-printed form along with `done`), results attach
/// once — length + flags capture every mutation the app performs. The trailing
/// bool folds in feed focus, but *only* for `Kind::User` entries (their block
/// color tracks focus): toggling focus then re-renders just the user blocks,
/// not the whole transcript.
fn fingerprint(e: &Entry, focused: bool) -> (usize, bool, usize, bool, bool) {
let (rlen, rerr) = e
.result
.as_ref()
.map_or((usize::MAX, false), |r| (r.content.len(), r.is_error));
let focus_bit = matches!(e.kind, Kind::User) && focused;
(e.content.len(), e.done, rlen, rerr, focus_bit)
}
/// Detach a `Line` from the text it borrows so it can outlive the app lock.
fn own_line(l: Line<'_>) -> Line<'static> {
Line {
spans: l
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect(),
style: l.style,
alignment: l.alignment,
}
}
/// Background used to highlight a visual-mode turn range in the session list.
const USER_BG: Color = Color::Indexed(17); // deep blue
/// Focus accent — Claude's orange (indexed so 256-color terminals match it).
/// This is the single source of truth for "the feed/pane has keyboard focus":
/// borders, the scroll thumb, the user-message markers and the user-message
/// blocks all use it when focused.
const ACCENT: Color = Color::Indexed(208); // orange
/// Background of a focused user-prompt block (the active accent).
const USER_BG_ACTIVE: Color = Color::Indexed(208); // orange
/// Background of an unfocused user-prompt block (dimmed grey).
const USER_BG_DIM: Color = Color::Indexed(238); // dark grey
/// Style for a user-prompt block: a filled orange box when the feed is focused,
/// a dim grey box when it isn't (focus is folded into the per-entry fingerprint
/// for `Kind::User` only, so toggling focus re-renders just these blocks). The
/// foreground is chosen by `color_on` so the text stays legible on either bg.
fn user_block_style(focused: bool) -> Style {
let bg = if focused { USER_BG_ACTIVE } else { USER_BG_DIM };
let s = Style::new().bg(bg).fg(color_on(bg));
if focused { s.bold() } else { s }
}
/// Pick a legible foreground (black or white) for text drawn on `bg`, from the
/// background's perceived luminance. A filled block sets its own bg, so this
/// keeps the text readable regardless of the user's light/dark terminal theme.
fn color_on(bg: Color) -> Color {
let (r, g, b) = rgb_of(bg);
let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
if luma > 140.0 { Color::Black } else { Color::White }
}
/// Approximate 8-bit RGB for a ratatui `Color`, enough to judge brightness:
/// RGB passes through; the xterm-256 indexed palette is decoded (16 base + the
/// 6×6×6 cube + the 24-step grey ramp); anything else falls back to mid grey.
fn rgb_of(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Indexed(i) => indexed_rgb(i),
_ => (128, 128, 128),
}
}
fn indexed_rgb(i: u8) -> (u8, u8, u8) {
const BASE: [(u8, u8, u8); 16] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
];
match i {
0..=15 => BASE[i as usize],
16..=231 => {
const STEP: [u8; 6] = [0, 95, 135, 175, 215, 255];
let n = i - 16;
(STEP[(n / 36) as usize], STEP[((n / 6) % 6) as usize], STEP[(n % 6) as usize])
}
_ => {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}
/// Render one entry to owned lines, including the blank separator row that
/// follows every entry in the feed. `focused` only affects user-prompt blocks.
fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
match &e.kind {
Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()),
Kind::User => {
let style = user_block_style(focused);
let w = (width as usize).max(1);
// Wrap to the inner feed width ourselves and pad every row to
// *exactly* w, so the block is a clean filled rectangle that ends
// flush with the borders — no reliance on the Paragraph's own
// wrapping (which left a stray, half-empty continuation row), and
// no trailing blank: the content is right-trimmed first.
let mut first = true;
for raw in e.content.trim_end().lines() {
let prefixed = format!("{}{}", if first { " " } else { " " }, sanitize(raw));
for seg in wrap_words(&prefixed, w) {
let mut row = seg;
let pad = w.saturating_sub(row.chars().count());
row.extend(std::iter::repeat_n(' ', pad));
lines.push(Line::from(Span::styled(row, style)));
}
first = false;
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(" ".repeat(w), style)));
}
}
Kind::Reminder => {
// Injected context Claude Code received — shown dim, like thinking.
lines.push(Line::from("⌁ system reminder").dark_gray().italic());
for l in e.content.lines() {
lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic());
}
}
Kind::Thinking => {
let head = if e.done { "✻ thought" } else { "✻ thinking…" };
lines.push(Line::from(head).magenta().italic());
for l in e.content.lines() {
lines.push(Line::from(l.to_string()).dark_gray().italic());
}
}
Kind::Text => {
lines.extend(crate::markdown::render(&e.content, width).into_iter().map(own_line));
}
Kind::Tool { name } => {
// Once the input JSON is complete, every tool gets a
// human-readable rendering; partial streams fall back to
// the raw JSON-fragment view.
let parsed = e
.done
.then(|| serde_json::from_str::<Value>(&e.content).ok())
.flatten();
match parsed {
Some(v) => render_tool(name, &v, e.result.as_ref(), &mut lines, width),
None => {
let head = if e.done {
format!("{name}")
} else {
format!("{name}")
};
lines.push(Line::from(head).yellow().bold());
for l in e.content.lines() {
lines.push(Line::from(format!(" {l}")).cyan());
}
}
}
}
Kind::Error => {
for l in e.content.lines() {
lines.push(Line::from(l.to_string()).red().bold());
}
}
}
lines.push(Line::default());
lines
}
/// Rows the lines occupy after wrapping to `width` (must match the wrap
/// configuration of the feed Paragraph).
fn wrapped_height(lines: &[Line<'static>], width: u16) -> usize {
Paragraph::new(Text::from(lines.to_vec()))
.wrap(Wrap { trim: false })
.line_count(width)
}
pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
// Keep the session list populated with this directory's on-disk history.
crate::sessions::spawn_scanner(app.clone());
// Refresh the `n` model picker from the live `claude` alias set.
crate::term::spawn_model_discovery(app.clone());
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.
// 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,
claude_focused: false,
fullscreen: false,
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(),
SetCursorStyle::DefaultUserShape,
DisableBracketedPaste,
DisableMouseCapture
);
ratatui::restore();
res
}
fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) {
if eui.visible {
eui.visible = false;
eui.claude_focused = false;
eui.fullscreen = 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;
if let Some(old) = app.lock().unwrap().embed_session.take() {
eui.past_embeds.insert(old);
}
}
return;
}
show_embed_pane(eui, app);
}
/// Show (spawning if needed) the claude pane and give it keyboard focus.
/// Moves the selection onto the embedded session — the pane is only drawn
/// while its session is selected.
fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
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();
if let Some(old) = a.embed_session.replace(t.session_id.clone()) {
eui.past_embeds.insert(old);
}
a.embed_grow = false;
// A fresh session has no traffic yet: give it a live row now
// so the selection (and the pane-visibility rule) has a key
// to point at. Tap::new finds this row by key and reuses it.
if !a.sessions.iter().any(|s| s.key == t.session_id) {
a.sessions
.push(Session::new(t.session_id.clone(), "(embedded)".into()));
}
drop(a);
eui.term = Some(t);
}
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
}
let mut a = app.lock().unwrap();
if let Some(key) = a.embed_session.clone() {
a.select_key(&key);
a.follow = true;
}
drop(a);
eui.visible = true;
eui.claude_focused = true;
}
/// `n` model picker: always spawn a *fresh* `claude --session-id <new uuid>`
/// (optionally `--model <model>`), killing any current pane first. Unlike
/// `show_embed_pane` this never reuses an existing child — the point of `n`
/// is to start a brand-new session without resume + /clear.
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
// Drop kills the child process (see EmbeddedTerm::drop).
if eui.term.take().is_some() {
let mut a = app.lock().unwrap();
if let Some(old) = a.embed_session.take() {
eui.past_embeds.insert(old);
}
a.embed_grow = false;
a.embed_grow_rows = None;
a.embed_clear_at = None;
}
match EmbeddedTerm::spawn(eui.port, 20, 80, model) {
Ok(t) => {
let mut a = app.lock().unwrap();
if let Some(old) = a.embed_session.replace(t.session_id.clone()) {
eui.past_embeds.insert(old);
}
a.embed_grow = false;
// Give the fresh session a live row so the selection (and the
// pane-visibility rule) has a key to point at (Tap::new reuses it).
if !a.sessions.iter().any(|s| s.key == t.session_id) {
a.sessions
.push(Session::new(t.session_id.clone(), "(embedded)".into()));
}
let key = t.session_id.clone();
a.select_key(&key);
a.follow = true;
a.clear_turn_focus();
drop(a);
eui.term = Some(t);
}
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
eui.visible = true;
eui.claude_focused = true;
}
/// Spawn (or replace) the embedded pane resuming a past session by UUID.
/// Any existing pane (live or dead) is killed and replaced — this is the
/// only expensive path, and it only runs from an explicit ctrl-↓ / `c`.
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
// Drop kills the child process (see EmbeddedTerm::drop).
if eui.term.take().is_some() {
let mut a = app.lock().unwrap();
if let Some(old) = a.embed_session.take() {
// That instance is dead now: its session needs no liveness guard.
eui.past_embeds.insert(old);
}
a.embed_grow = false;
a.embed_grow_rows = None;
a.embed_clear_at = None;
}
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) {
Ok(t) => {
let mut a = app.lock().unwrap();
a.embed_session = Some(t.session_id.clone());
a.embed_grow = false;
// Promote the session to a live row, pre-filled from the on-disk
// transcript: no API traffic flows until the next turn, so it
// would be blank. Always re-read the file — a cached view may
// have been built along a dead-branch path, while the resumed
// claude continues from the trunk.
a.history.remove(session_id);
if !a.sessions.iter().any(|s| s.key == session_id)
&& let Some(s) = crate::sessions::load_history(session_id)
{
a.sessions.push(s);
}
a.select_key(session_id);
a.follow = true;
drop(a);
eui.term = Some(t);
}
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
eui.visible = true;
eui.claude_focused = true;
}
/// ctrl-↓ / `c`: attach the embedded pane to the *selected* session. The
/// cheap cases (reveal/focus the live pane, spawn the first instance) are
/// instant; only attaching to a different session kills + respawns claude.
fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) {
enum Plan {
Fresh,
Reveal,
Resume(String),
Guard(String),
}
let plan = {
let mut a = app.lock().unwrap();
a.filter_popup = None;
match a.selected_key() {
// Nothing anywhere yet → fresh `claude --session-id <new uuid>`.
None => Plan::Fresh,
Some(key) if a.embed_session.as_deref() == Some(key.as_str()) => {
if eui.term.as_ref().is_some_and(|t| !t.exited()) {
Plan::Reveal
} else if a
.sessions
.iter()
.find(|s| s.key == key)
.is_some_and(|s| !s.entries.is_empty())
{
// Pane died on its own session → respawn resuming it.
Plan::Resume(key)
} else {
// Died before any turn: nothing to resume, start fresh.
Plan::Fresh
}
}
Some(key) => {
let live = a.sessions.iter().any(|s| s.key == key);
if live && !eui.past_embeds.contains(&key) {
// External instance on our port; it may still be running
// (an idle claude sends no traffic, so we can't know).
Plan::Guard(key)
} else {
Plan::Resume(key)
}
}
}
};
match plan {
Plan::Fresh => show_embed_pane(eui, app),
Plan::Reveal => {
eui.visible = true;
eui.claude_focused = true;
}
Plan::Resume(key) => {
eui.force_resume = None;
show_embed_resume(eui, app, &key);
}
Plan::Guard(key) => {
let armed = eui
.force_resume
.as_ref()
.is_some_and(|(k, t)| *k == key && t.elapsed() < Duration::from_secs(3));
if armed {
eui.force_resume = None;
show_embed_resume(eui, app, &key);
} else {
eui.force_resume = Some((key, Instant::now()));
app.lock().unwrap().status =
"session may be live in another claude instance — ctrl-↓ again to resume anyway"
.into();
}
}
}
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: SharedApp,
eui: &mut EmbedUi,
) -> 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 = {
let mut a = app.lock().unwrap();
if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) {
a.embed_clear_at = None;
true
} else {
false
}
};
if clear_due
&& let Some(et) = &eui.term
&& !et.exited()
{
et.clear_screen();
}
if !event::poll(Duration::from_millis(33))? {
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().
if let Event::Mouse(m) = &ev {
match m.kind {
MouseEventKind::ScrollUp => {
let mut a = app.lock().unwrap();
a.follow = false;
a.scroll = a.scroll.saturating_sub(3);
}
MouseEventKind::ScrollDown => {
// follow re-engages automatically when draw() clamps
// the scroll to the bottom.
let mut a = app.lock().unwrap();
a.follow = false;
a.scroll += 3;
}
MouseEventKind::Down(MouseButton::Left) => {
sel = Some(Selection {
start: (m.column, m.row),
end: (m.column, m.row),
dragging: true,
copy_pending: false,
});
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(s) = sel.as_mut()
&& s.dragging
{
s.end = (m.column, m.row);
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(s) = sel.as_mut()
&& s.dragging
{
s.dragging = false;
if s.start == s.end {
sel = None; // plain click, nothing to copy
} else {
s.copy_pending = true;
}
}
}
_ => {}
}
continue;
}
if let Event::Key(k) = ev {
if k.kind == KeyEventKind::Release {
continue;
}
// `CT_DEBUG_KEYS=1`: surface every key event in the status bar,
// for diagnosing what the outer terminal actually delivers.
if std::env::var_os("CT_DEBUG_KEYS").is_some() {
app.lock().unwrap().status =
format!("key: {:?} mods={:?} kind={:?}", k.code, k.modifiers, k.kind);
}
// Pane controls, available in every state (alt-keys are out:
// they compose characters on some keyboard layouts):
// F2 show/hide the claude pane
// ctrl-↓ focus the claude pane (showing it if hidden)
// ctrl-↑ focus the feed
// ctrl-f toggle pane fullscreen (only while the pane is focused)
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
// ctrl-q quits from anywhere — in particular while the claude pane
// is focused, where plain `q` is forwarded to the child.
if ctrl && k.code == KeyCode::Char('q') {
return Ok(());
}
if k.code == KeyCode::F(2) {
if k.kind == KeyEventKind::Press {
toggle_embed(eui, &app);
}
continue;
}
if ctrl && k.code == KeyCode::Down {
if k.kind == KeyEventKind::Press {
attach_selected(eui, &app);
}
continue;
}
if ctrl && k.code == KeyCode::Up {
eui.claude_focused = false;
// The feed would be invisible behind a fullscreen pane.
eui.fullscreen = false;
continue;
}
if ctrl && k.code == KeyCode::Char('f') && eui.focused() {
if k.kind == KeyEventKind::Press {
eui.fullscreen = !eui.fullscreen;
}
continue;
}
// While the claude pane has focus, everything else belongs to it.
if eui.focused() {
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.merged_len();
if k.code == KeyCode::Char('c') && ctrl {
return Ok(());
}
// Filter popup captures input while open.
if let Some(sel) = a.filter_popup {
let n = FILTER_LABELS.len();
match k.code {
KeyCode::Char(' ') => a.filters[sel] = !a.filters[sel],
KeyCode::Up | KeyCode::Char('k') => {
a.filter_popup = Some((sel + n - 1) % n)
}
KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n),
KeyCode::Char('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
a.filter_popup = None
}
_ => {}
}
continue;
}
// Model picker (opened with `a`): j/k move, Enter spawns a fresh
// session with the chosen model, esc/a/q cancels.
if let Some(msel) = a.model_popup {
let n = a.model_choices.len().max(1);
match k.code {
KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n),
KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n),
KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None,
KeyCode::Enter => {
a.model_popup = None;
let model = a.model_choices.get(msel).map(|c| c.1.clone());
drop(a);
if let Some(model) = model {
show_embed_new(eui, &app, &model);
}
}
_ => {}
}
continue;
}
match k.code {
KeyCode::Char('q') => return Ok(()),
// Esc unwinds one layer: visual mode → turn tree → quit.
KeyCode::Esc => match a.expanded.as_mut() {
Some(e) if e.visual.is_some() => e.visual = None,
Some(_) => a.expanded = None,
None => return Ok(()),
},
KeyCode::Char('f') => a.filter_popup = Some(0),
// a → pick a model, then spawn a brand-new session (no need to
// resume + /clear just to get a fresh chat).
KeyCode::Char('a') => a.model_popup = Some(0),
// n / N → jump the feed scroll to the next / previous user
// prompt (honoured on the next draw, where entry heights live).
KeyCode::Char('n') => {
a.follow = false;
a.prompt_jump = Some(true);
}
KeyCode::Char('N') => {
a.follow = false;
a.prompt_jump = Some(false);
}
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
// c → attach the most recent past session (like `claude -c`):
// select it (the scanner keeps disk_sessions newest-first),
// then run the normal attach logic.
KeyCode::Char('c') => {
match a.disk_sessions.first().map(|d| d.uuid.clone()) {
None => a.status = "no past sessions found for this directory".into(),
Some(uuid) => {
a.select_key(&uuid);
a.clear_turn_focus();
drop(a);
attach_selected(eui, &app);
}
}
continue;
}
// Turn tree (sessions panel): space toggles the selected
// session's tree, →/l expands / steps into the turns, ←/h
// steps out / collapses (yazi-style), v anchors a visual
// range, b materializes a new decoupled session from the
// highlighted turn (its chain) or the visual selection.
KeyCode::Char(' ') => a.toggle_expand(),
KeyCode::Right | KeyCode::Char('l') => a.tree_right(),
KeyCode::Left | KeyCode::Char('h') => a.tree_left(),
KeyCode::Char('v') => a.toggle_visual(),
KeyCode::Char('b') => match a.branch_selected() {
Ok(u) => {
a.status = format!(
"branched → {} (ctrl-↓ to start it)",
u.chars().take(8).collect::<String>()
);
}
Err(e) => a.status = e,
},
// Tab / BackTab cycle sessions. `a` is the new-session picker;
// `n`/`N` jump between user prompts; `p` is no longer a
// back-tab mirror.
KeyCode::Tab if nsess > 0 => {
a.selected = (a.selected + 1) % nsess;
a.follow = true;
a.clear_turn_focus();
}
KeyCode::BackTab if nsess > 0 => {
a.selected = (a.selected + nsess - 1) % nsess;
a.follow = true;
a.clear_turn_focus();
}
// j/k/↑/↓ drive the session/turn highlight, never the feed —
// the feed scrolls with the wheel (or PgUp/PgDn/g/G).
KeyCode::Up | KeyCode::Char('k') => a.nav(false),
KeyCode::Down | KeyCode::Char('j') => a.nav(true),
KeyCode::PageUp => {
a.follow = false;
a.scroll = a.scroll.saturating_sub(20);
}
KeyCode::PageDown => {
a.follow = false;
a.scroll += 20;
}
KeyCode::Home | KeyCode::Char('g') => {
a.follow = false;
a.scroll = 0;
}
KeyCode::End | KeyCode::Char('G') => a.follow = true,
_ => {}
}
}
}
}
fn draw(
f: &mut Frame,
app: &SharedApp,
eui: &mut EmbedUi,
selection: &mut Option<Selection>,
cache: &mut FeedCache,
) {
let mut a = app.lock().unwrap();
// Embedded pane height: nothing when hidden. The pane is meant to be
// prompt-only (the feed above shows the context; term.rs crops Claude
// Code's status/hint rows), so the default is just tall enough for the
// input box plus the statusLine. Interactive prompts grow it: sized from
// the question's option count when the tap could estimate it, 75% as
// fallback.
const EMBED_COMPACT: u16 = 9; // 7 inner rows + borders
// The pane is drawn only while the feed selection is on the embedded
// session: tabbing away hides it (the child keeps running — hide, don't
// kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere.
let selected_is_embed =
a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref();
let show_embed = eui.visible && eui.term.is_some() && selected_is_embed;
if !show_embed {
// An invisible pane must not swallow keystrokes (the selection can
// move under us, e.g. a new session auto-jump).
eui.claude_focused = false;
eui.fullscreen = false;
}
let embed_h = if show_embed {
let total = f.area().height;
if eui.fullscreen {
// Whole screen minus the footer and the 1-row Min(1) the feed
// area keeps (layout below still reserves it).
total.saturating_sub(2)
} else {
let cap = total.saturating_sub(6).max(1);
if a.embed_grow {
let h = a
.embed_grow_rows
.map(|r| r.saturating_add(3)) // + borders + statusLine row
.unwrap_or((total as u32 * 75 / 100) as u16);
h.clamp(EMBED_COMPACT.min(cap), cap)
} else {
EMBED_COMPACT.min(cap)
}
}
} else {
0
};
// Keyboard focus lives in exactly one place: the claude pane (when it has
// focus) or the feed/sessions area otherwise. One rule for every panel:
// focused → bold accent border, unfocused → dimmed. So the feed (and the
// sessions panel, if shown) and the pane all read the same way, and the
// dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on.
let border_style = |focused: bool| {
if focused {
Style::new().fg(ACCENT).bold()
} else {
Style::new().dark_gray()
}
};
let feed_focused = !eui.focused();
let [main, embed_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(embed_h),
Constraint::Length(1),
])
.areas(f.area());
// Uniform: the sessions panel is always half the width, so titles have
// room to read the same whether or not a turn tree is expanded.
let left_width = if a.show_sessions { main.width / 2 } else { 0 };
let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)])
.areas(main);
let live_n = a.sessions.len();
let stubs = a.visible_stubs();
let sel = a.selected.min((live_n + stubs.len()).saturating_sub(1));
a.selected = sel;
let sel_key = a.selected_key();
// Session list: live sessions first, then this directory's past sessions
// as dimmed stubs (kept fresh by the scanner thread — no I/O here). The
// expanded session's turn rows render directly under its row, abandoned
// branches indented one level under their fork point (⑂).
if a.show_sessions {
// Inner content width (panel minus its border): titles wrap to this,
// turn labels truncate to it.
let inner_w = left.width.saturating_sub(2).max(1) as usize;
let mut items: Vec<ListItem> = Vec::new();
let mut flat_sel = 0usize;
let vis_range = a.expanded.as_ref().and_then(|e| {
let (av, p) = (e.visual?, e.sel?);
Some((av.min(p), av.max(p)))
});
let white = Style::new().fg(Color::White);
// The session whose `claude` child we spawned and is still alive: the
// one running instance this app owns (cleared the moment the pane
// exits). It gets a bright accent marker + accent title so it reads as
// "running here" at a glance; external live sessions only show a green
// dot while they're actively streaming (their instance may have ended —
// liveness is unknowable), and disk stubs stay dimmed.
let embed_key = a.embed_session.clone();
for m in 0..live_n + stubs.len() {
// Every session is a multi-line item: the full title (white,
// wrapped to the panel width — continuation rows aligned under
// it) followed by a dimmed meta row (status dot + id + model for
// live sessions, just the id for disk stubs).
let (uuid, lead, title, meta, title_style) = if m < live_n {
let s = &a.sessions[m];
let is_embed = embed_key.as_deref() == Some(s.key.as_str());
let lead = if is_embed {
"".fg(ACCENT).bold()
} else if s.active > 0 {
"".green()
} else {
"".dark_gray()
};
let id: String = s.key.chars().take(8).collect();
let meta = if is_embed {
format!("{id} · {} · running", short_model(&s.model))
} else {
format!("{id} · {}", short_model(&s.model))
};
let title_style = if is_embed { Style::new().fg(ACCENT).bold() } else { white };
(s.key.clone(), lead, live_title(s), meta, title_style)
} else {
let d = &a.disk_sessions[stubs[m - live_n]];
let id: String = d.uuid.chars().take(8).collect();
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id, white)
};
let mut rows: Vec<Line> = Vec::new();
for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2))
.into_iter()
.enumerate()
{
if i == 0 {
rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)]));
} else {
rows.push(Line::from(Span::styled(format!(" {w}"), title_style)));
}
}
rows.push(Line::from(format!(" {meta}")).dark_gray());
let on_sel_row = m == sel;
let turn_hl = a
.expanded
.as_ref()
.filter(|e| e.uuid == uuid)
.and_then(|e| e.sel);
if on_sel_row && turn_hl.is_none() {
flat_sel = items.len();
}
items.push(ListItem::new(rows));
if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) {
for (p, &t) in e.tree.display.iter().enumerate() {
let turn = &e.tree.turns[t];
let bullet = if turn.depth > 0 { "" } else { "" };
// Indent turns past the title gutter, then by tree depth;
// labels truncate (never wrap) so one row = one turn.
let prefix = format!(" {}{bullet} ", " ".repeat(turn.depth.min(6)));
let avail = inner_w.saturating_sub(prefix.chars().count());
let txt = format!("{prefix}{}", truncate_str(&turn.label, avail));
let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) {
Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG)))
} else {
Line::from(txt).dark_gray()
};
if on_sel_row && turn_hl == Some(p) {
flat_sel = items.len();
}
items.push(ListItem::new(line));
}
}
}
let mut ls = ListState::default();
if !items.is_empty() {
ls.select(Some(flat_sel));
}
f.render_stateful_widget(
List::new(items)
.block(
Block::bordered().title(" sessions ").border_style(border_style(feed_focused)),
)
.highlight_style(ratatui::style::Style::new().reversed()),
left,
&mut ls,
);
}
// What the feed shows: a highlighted turn views the on-disk transcript
// along the path *through that turn* (works for live sessions too — pure
// viewing); a stub selection views its whole file; otherwise the live
// in-memory session. Path views are cached per uuid and rebuilt only
// when the requested leaf changes (a few ms even for MB-sized files).
let turn_view: Option<usize> = a
.expanded
.as_ref()
.filter(|e| sel_key.as_deref() == Some(e.uuid.as_str()))
.and_then(|e| e.sel.map(|p| e.tree.display[p]));
let hist_view: Option<(String, Option<usize>)> = match (&sel_key, turn_view) {
(Some(u), Some(t)) => {
let tree = &a.expanded.as_ref().unwrap().tree;
Some((u.clone(), Some(tree.trunk_leaf(t))))
}
(Some(u), None) if sel >= live_n => Some((u.clone(), None)),
_ => None,
};
if let Some((u, leaf)) = &hist_view
&& a.history.get(u).is_none_or(|h| h.leaf != *leaf)
{
let aref = &mut *a;
let built = match (*leaf, aref.expanded.as_ref()) {
(Some(l), Some(e)) => crate::sessions::load_view(u, Some((&e.tree, l))),
_ => crate::sessions::load_view(u, None),
};
let h = built.unwrap_or_else(|| {
let mut s = Session::new(u.clone(), "(disk)".into());
s.entries.push(Entry::meta("(empty or unreadable transcript)".into()));
crate::sessions::HistoryView { session: s, leaf: *leaf, turn_entries: Vec::new() }
});
aref.history.insert(u.clone(), h);
}
// Feed
let follow = a.follow;
let scroll0 = a.scroll;
let filters = a.filters;
let mut new_scroll = scroll0;
let mut new_follow = follow;
// Scroll the feed to the highlighted turn's first entry, once per
// highlight move (the offset needs the freshly cached entry heights).
let scroll_target: Option<usize> = match (a.turn_dirty, &hist_view, turn_view) {
(true, Some((u, _)), Some(t)) => a
.history
.get(u)
.and_then(|h| h.turn_entries.iter().find(|(ti, _)| *ti == t))
.map(|&(_, e)| e),
_ => None,
};
a.turn_dirty = false;
// `n`/`N`: jump to the next/previous user prompt. Taken once, here, before
// the feed borrow so we can clear it (offset computed below from the cache).
let prompt_jump = a.prompt_jump.take();
let (feed_session, feed_leaf): (Option<&Session>, Option<usize>) = match &hist_view {
Some((u, _)) => {
let h = a.history.get(u);
(h.map(|h| &h.session), h.and_then(|h| h.leaf))
}
None => (a.sessions.get(sel), None),
};
let feed_live = hist_view.is_none();
if let Some(s) = feed_session {
let feed_width = right.width.saturating_sub(2);
// (In)validate the render cache: a width change, session switch, or
// a different transcript view of the same session (live vs on-disk,
// another tree path) invalidates everything, otherwise only entries
// whose fingerprint changed (the in-flight one, or a tool entry
// whose result attached) are re-rendered.
if cache.session_key != s.key
|| cache.width != feed_width
|| cache.leaf != feed_leaf
|| cache.live != feed_live
{
cache.session_key = s.key.clone();
cache.width = feed_width;
cache.leaf = feed_leaf;
cache.live = feed_live;
cache.entries.clear();
}
for (i, e) in s.entries.iter().enumerate() {
let fp = fingerprint(e, feed_focused);
if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) {
let lines = entry_lines(e, feed_width, feed_focused);
let height = wrapped_height(&lines, feed_width);
let ce = CachedEntry { fingerprint: fp, lines, height };
if i < cache.entries.len() {
cache.entries[i] = ce;
} else {
cache.entries.push(ce);
}
}
}
// Visible (filter-passing) entries and their total wrapped height.
let visible: Vec<usize> = s
.entries
.iter()
.enumerate()
.filter(|(_, e)| filters[filter_index(&e.kind)])
.map(|(i, _)| i)
.collect();
let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum();
let height = right.height.saturating_sub(2) as usize;
let max_scroll = total.saturating_sub(height);
new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) };
if let Some(target) = scroll_target {
// Pin the highlighted turn's first entry to the viewport top.
new_scroll = visible
.iter()
.take_while(|&&i| i < target)
.map(|&i| cache.entries[i].height)
.sum::<usize>()
.min(max_scroll);
new_follow = false;
}
if let Some(down) = prompt_jump {
// Wrapped-row offset of each visible user prompt's first row.
let mut offsets: Vec<usize> = Vec::new();
let mut acc = 0usize;
for &i in &visible {
if matches!(s.entries[i].kind, Kind::User) {
offsets.push(acc);
}
acc += cache.entries[i].height;
}
let pick = if down {
offsets.iter().copied().find(|&o| o > scroll0)
} else {
offsets.iter().rev().copied().find(|&o| o < scroll0)
};
if let Some(o) = pick {
new_scroll = o.min(max_scroll);
new_follow = false;
}
}
// Reaching the bottom re-engages follow automatically.
if new_scroll >= max_scroll {
new_follow = true;
}
// Window: hand ratatui only the entries intersecting the viewport,
// with the residual offset into the first one. Scroll state stays
// usize end-to-end, so feeds longer than u16::MAX rows keep working.
let mut lines: Vec<Line<'static>> = Vec::new();
let mut acc = 0usize; // wrapped rows before the current entry
let mut skipped = 0usize; // wrapped rows before the first included entry
let mut included = false;
for &i in &visible {
let h = cache.entries[i].height;
if acc + h <= new_scroll {
acc += h;
continue; // entirely above the viewport
}
if acc >= new_scroll + height {
break; // below the viewport
}
if !included {
skipped = acc;
included = true;
}
lines.extend(cache.entries[i].lines.iter().cloned());
acc += h;
}
let residual = new_scroll.saturating_sub(skipped);
let title = if a.show_sessions {
format!(
" {} · in {} · out {} ",
s.model,
fmt_tokens(s.input_tokens),
fmt_tokens(s.output_tokens)
)
} else {
let id: String = s.key.chars().take(8).collect();
format!(
" {} · {} · in {} · out {} ",
id,
s.model,
fmt_tokens(s.input_tokens),
fmt_tokens(s.output_tokens)
)
};
let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
f.render_widget(
p.block(Block::bordered().title(title).border_style(border_style(feed_focused)))
// residual < first visible entry's height; an entry would
// need >65k wrapped rows of its own to hit the clamp.
.scroll((residual.min(u16::MAX as usize) as u16, 0)),
right,
);
// Right-border overlay: first `*` markers showing where the user's own
// messages sit in the whole conversation (a minimap of prompts), then
// the scroll thumb painted on top of them where they coincide.
if height > 0 && right.width >= 2 {
let col = right.x + right.width - 1;
// Map a wrapped-row offset within the transcript to a border row.
// When everything fits, offsets are 1:1 with screen rows; once
// scrollable, compress the whole transcript onto the track.
let track_row = |offset: usize| -> u16 {
let r = if total <= height {
offset
} else {
offset * height / total
};
r.min(height - 1) as u16
};
// Markers track focus like the prompt blocks: orange when focused,
// dim grey when not.
let marker_style = if feed_focused {
Style::new().fg(ACCENT).bold()
} else {
Style::new().fg(Color::DarkGray)
};
let mut acc = 0usize;
{
let buf = f.buffer_mut();
for &i in &visible {
if matches!(s.entries[i].kind, Kind::User) {
let y = right.y + 1 + track_row(acc);
buf[(col, y)].set_symbol("*").set_style(marker_style);
}
acc += cache.entries[i].height;
}
}
// Scroll thumb: a solid block marking the visible window's position
// within the whole transcript, drawn last so it sits *on top* of a
// marker at the same row. Shown only when scrollable.
if total > height {
let thumb = ((height * height) / total).max(1).min(height);
let max_scroll = total - height;
let thumb_top = if max_scroll == 0 {
0
} else {
(new_scroll * (height - thumb)) / max_scroll
};
let style = if feed_focused {
Style::new().fg(ACCENT)
} else {
Style::new().fg(Color::Gray)
};
let buf = f.buffer_mut();
for k in 0..thumb {
let y = right.y + 1 + (thumb_top + k) as u16;
buf[(col, y)].set_symbol("").set_style(style);
}
}
}
} else {
f.render_widget(
Paragraph::new(format!(
"\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:{}",
eui.port
))
.dark_gray()
.block(Block::bordered().border_style(border_style(feed_focused))),
right,
);
}
a.scroll = new_scroll;
a.follow = new_follow;
// 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();
let id: String = et.session_id.chars().take(8).collect();
let title = if exited {
format!(" claude · {id} · exited ")
} else {
format!(" claude · {id} ")
};
// Bold accent border = keyboard focus; dimmed = unfocused (same rule
// as the feed/sessions panels above).
let block = Block::bordered().title(title).border_style(border_style(embed_focused));
let inner = block.inner(embed_area);
f.render_widget(block, embed_area);
if exited {
f.render_widget(
Paragraph::new("\n claude exited — F2 to close this pane")
.dark_gray(),
inner,
);
} else {
// Fullscreen shows the child's screen verbatim (no chrome crop,
// PTY sized exactly); the compact pane crops Claude Code chrome.
let crop = !eui.fullscreen;
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() {
"space toggle · j/k move · f/esc close"
} else if a.model_popup.is_some() {
"enter new session · j/k move · esc cancel"
} else if embed_focused {
"ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude"
} else if visual_on {
"j/k extend · b branch selection · esc cancel"
} else if a.on_turns() {
"j/k turns · v visual · b branch · ←/space close · wheel scrolls feed"
} else if show_embed {
"ctrl-↓ claude · n new · q quit · j/k move · space tree · f filter · F2 hide"
} else {
"q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach"
};
f.render_widget(
Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()),
footer,
);
// Filter popup
if let Some(fsel) = a.filter_popup {
let w = 26u16.min(main.width);
let h = (FILTER_LABELS.len() as u16 + 2).min(main.height);
let area = Rect {
x: main.x + (main.width.saturating_sub(w)) / 2,
y: main.y + (main.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, area);
let items: Vec<ListItem> = FILTER_LABELS
.iter()
.enumerate()
.map(|(i, name)| {
let mark = if a.filters[i] { "[x]" } else { "[ ]" };
ListItem::new(format!(" {mark} {name}"))
})
.collect();
let mut ls = ListState::default();
ls.select(Some(fsel));
f.render_stateful_widget(
List::new(items)
.block(Block::bordered().title(" filter "))
.highlight_style(ratatui::style::Style::new().reversed()),
area,
&mut ls,
);
}
// Model picker popup (n → choose a model → fresh session).
if let Some(msel) = a.model_popup {
let w = 30u16.min(main.width);
let h = (a.model_choices.len() as u16 + 2).min(main.height);
let area = Rect {
x: main.x + (main.width.saturating_sub(w)) / 2,
y: main.y + (main.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, area);
let items: Vec<ListItem> = a
.model_choices
.iter()
.map(|(label, _)| ListItem::new(format!(" {label}")))
.collect();
let mut ls = ListState::default();
ls.select(Some(msel));
f.render_stateful_widget(
List::new(items)
.block(Block::bordered().title(" new session "))
.highlight_style(ratatui::style::Style::new().reversed()),
area,
&mut ls,
);
}
drop(a); // release the app lock before the mouse-selection pass
// Mouse selection: while dragging, paint a reversed-video highlight over
// the (fully rendered) buffer; on release, read the selected cells out of
// the buffer instead and copy them to the clipboard.
if let Some(s) = selection.take() {
// Linear (terminal-style) selection: order endpoints by (row, col).
let (mut from, mut to) = (s.start, s.end);
if (from.1, from.0) > (to.1, to.0) {
std::mem::swap(&mut from, &mut to);
}
let buf = f.buffer_mut();
// Clip the selection to one panel's *inner* content box so a multi-row
// sweep grabs only that panel's text — never an adjacent panel or the
// box-drawing borders between them (a linear selection otherwise spans
// the full screen width on its middle rows, which is what produced the
// border/padding artefacts). The panel is chosen by where the drag
// began, hit-tested against each panel's *outer* rect so starting on a
// border or in the margin still resolves to the panel; the feed is the
// default, since that is what prose gets copied from.
let contains = |r: Rect, p: (u16, u16)| {
p.0 >= r.left() && p.0 < r.right() && p.1 >= r.top() && p.1 < r.bottom()
};
let outer = if left.width > 0 && contains(left, s.start) {
left
} else if embed_h > 0 && contains(embed_area, s.start) {
embed_area
} else {
right
};
// Inner content box = the bordered rect minus its 1-cell border.
let region = Rect {
x: outer.x.saturating_add(1),
y: outer.y.saturating_add(1),
width: outer.width.saturating_sub(2),
height: outer.height.saturating_sub(2),
};
let (clip_l, clip_r, clip_t, clip_b) = (
region.left(),
region.right().saturating_sub(1),
region.top(),
region.bottom().saturating_sub(1),
);
let y_start = from.1.max(clip_t);
let y_end = to.1.min(clip_b);
let mut lines: Vec<String> = Vec::new();
for y in y_start..=y_end {
let x_from = (if y == from.1 { from.0 } else { clip_l }).max(clip_l);
let x_to = (if y == to.1 { to.0 } else { clip_r }).min(clip_r);
if x_from > x_to {
if s.copy_pending {
lines.push(String::new());
}
continue;
}
let mut line = String::new();
for x in x_from..=x_to {
if let Some(c) = buf.cell_mut(Position::new(x, y)) {
if s.copy_pending {
line.push_str(c.symbol());
} else {
c.set_style(Style::new().add_modifier(Modifier::REVERSED));
}
}
}
if s.copy_pending {
// Drop trailing padding spaces (the buffer is space-filled to
// the panel width) so only real text — and its line breaks —
// ends up on the clipboard.
lines.push(line.trim_end().to_string());
}
}
if s.copy_pending {
// Trim blank rows off both ends: the empty padding lines above and
// below the text would otherwise paste as stray newlines (each one
// a submit in a prompt). Interior blanks (paragraph breaks) stay.
while lines.first().is_some_and(|l| l.is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
let copied = lines.join("\n");
osc52_copy(&copied);
app.lock().unwrap().status =
format!("copied {} chars to clipboard", copied.chars().count());
// selection stays None: the highlight disappears with the copy.
} else {
*selection = Some(s);
}
}
}
/// Copy text to the system clipboard via the OSC 52 escape sequence (works
/// over SSH; the outer terminal must support it, as it must for Claude Code).
fn osc52_copy(text: &str) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()));
let _ = out.flush();
}
fn base64(data: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
s.push(A[(n >> 18) as usize & 63] as char);
s.push(A[(n >> 12) as usize & 63] as char);
s.push(if chunk.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' });
s.push(if chunk.len() > 2 { A[n as usize & 63] as char } else { '=' });
}
s
}
fn short_model(m: &str) -> String {
m.strip_prefix("claude-").unwrap_or(m).to_string()
}
/// A human title for a live session: the first line of its first user prompt
/// (what the session is *about*), falling back to the model / a placeholder
/// before any prompt has streamed in.
fn live_title(s: &Session) -> String {
if let Some(e) = s.entries.iter().find(|e| matches!(e.kind, Kind::User)) {
let first = e.content.lines().map(str::trim).find(|l| !l.is_empty());
if let Some(t) = first.filter(|t| !t.is_empty()) {
return t.to_string();
}
}
if s.entries.is_empty() {
"(new session)".into()
} else {
short_model(&s.model)
}
}
/// Greedy word-wrap to `width` columns (char-counted). Words longer than the
/// width are hard-split. Always returns at least one (possibly empty) row.
fn wrap_words(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_len = 0usize;
let push_word = |out: &mut Vec<String>, cur: &mut String, cur_len: &mut usize, word: &str| {
let wlen = word.chars().count();
if *cur_len == 0 {
// start of a row
} else if *cur_len + 1 + wlen <= width {
cur.push(' ');
*cur_len += 1;
} else {
out.push(std::mem::take(cur));
*cur_len = 0;
}
if wlen <= width {
cur.push_str(word);
*cur_len += wlen;
} else {
// hard-split an over-long word
for c in word.chars() {
if *cur_len == width {
out.push(std::mem::take(cur));
*cur_len = 0;
}
cur.push(c);
*cur_len += 1;
}
}
};
for word in text.split_whitespace() {
push_word(&mut out, &mut cur, &mut cur_len, word);
}
out.push(cur);
if out.is_empty() {
out.push(String::new());
}
out
}
/// Single-line truncation with an ellipsis when the text doesn't fit.
fn truncate_str(s: &str, width: usize) -> String {
let s = sanitize(s);
if s.chars().count() <= width {
return s;
}
if width == 0 {
return String::new();
}
let mut out: String = s.chars().take(width - 1).collect();
out.push('…');
out
}
/// Per-tool human-readable rendering, plus the tool_result (if it has come
/// back in a subsequent request) attached underneath.
fn render_tool<'a>(
name: &str,
input: &Value,
result: Option<&ToolResult>,
out: &mut Vec<Line<'a>>,
width: u16,
) {
let sf = |k: &str| input.get(k).and_then(Value::as_str);
match name.to_ascii_lowercase().as_str() {
// Diff/content view; success confirmations are noise, only surface failures.
"write" | "edit" if render_file_tool(name, input, out, width) => {
if result.is_some_and(|r| r.is_error) {
push_result(out, result, None);
}
}
"read" => {
let mut head = vec![
"⚙ Read ".yellow().bold(),
sf("file_path").unwrap_or("?").to_string().bold(),
];
// offset/limit (or pages) = an explicit range: show the whole
// result. Plain reads get clipped to 5 lines.
let ranged = input.get("offset").is_some()
|| input.get("limit").is_some()
|| input.get("pages").is_some();
if ranged {
let mut parts = Vec::new();
if let Some(o) = input.get("offset").and_then(Value::as_u64) {
parts.push(format!("offset {o}"));
}
if let Some(l) = input.get("limit").and_then(Value::as_u64) {
parts.push(format!("limit {l}"));
}
if let Some(p) = sf("pages") {
parts.push(format!("pages {p}"));
}
head.push(format!(" ({})", parts.join(", ")).dark_gray());
}
out.push(Line::from(head));
push_result(out, result, if ranged { None } else { Some(5) });
}
"bash" => {
let cmd = sf("command").unwrap_or("?");
let mut cmd_lines = cmd.lines();
out.push(Line::from(vec![
"⚙ Bash ".yellow().bold(),
sanitize(cmd_lines.next().unwrap_or("")).cyan(),
]));
for l in cmd_lines {
out.push(Line::from(format!(" {}", sanitize(l))).cyan());
}
push_result(out, result, None);
}
"glob" | "grep" => {
out.push(Line::from(vec![
format!("{name} ").yellow().bold(),
format!("\"{}\"", sf("pattern").unwrap_or("?")).cyan(),
" in ".dark_gray(),
sf("path").unwrap_or(".").to_string().into(),
]));
push_result(out, result, None);
}
"todowrite" => {
out.push(Line::from("⚙ Todos").yellow().bold());
for t in input
.get("todos")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
let content = t.get("content").and_then(Value::as_str).unwrap_or("?");
let row = |mark: &str| format!(" {mark} {}", sanitize(content));
out.push(match t.get("status").and_then(Value::as_str) {
Some("completed") => Line::from(row("")).green(),
Some("in_progress") => Line::from(row("")).yellow(),
_ => Line::from(row("")).dark_gray(),
});
}
// The result just echoes the list back; only surface failures.
if result.is_some_and(|r| r.is_error) {
push_result(out, result, None);
}
}
// Generic fallback: tool name header, inputs as `key: value` rows.
_ => {
out.push(Line::from(format!("{name}")).yellow().bold());
match input.as_object() {
Some(obj) => {
for (k, v) in obj {
let val = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
out.push(Line::from(vec![
format!(" {k}: ").dark_gray(),
one_line(&val).cyan(),
]));
}
}
None => {
for l in input.to_string().lines() {
out.push(Line::from(format!(" {}", sanitize(l))).cyan());
}
}
}
push_result(out, result, None);
}
}
}
/// Renders a tool_result under its tool entry: `⎿`-marked, dimmed (red when
/// `is_error`). `limit` clips to the first N lines with a "N more lines" tail.
fn push_result<'a>(out: &mut Vec<Line<'a>>, result: Option<&ToolResult>, limit: Option<usize>) {
let Some(r) = result else { return };
if r.content.is_empty() {
if r.is_error {
out.push(Line::from(" ⎿ (error)").red());
}
return;
}
let total = r.content.lines().count();
let shown = limit.map_or(total, |n| n.min(total));
for (i, l) in r.content.lines().take(shown).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let line = format!("{prefix}{}", sanitize(l));
out.push(if r.is_error {
Line::from(line).red()
} else {
Line::from(line).dark_gray()
});
}
if shown < total {
out.push(
Line::from(format!(" {} more lines", total - shown))
.dark_gray()
.italic(),
);
}
}
/// First line only, clipped, with an ellipsis when anything was dropped.
fn one_line(s: &str) -> String {
const MAX: usize = 120;
let first = s.lines().next().unwrap_or("");
let truncated = first.chars().count() > MAX;
let multiline = s.contains('\n');
let clipped: String = first.chars().take(MAX).collect();
if truncated || multiline {
format!("{}", sanitize(&clipped))
} else {
sanitize(&clipped)
}
}
/// Dark diff backgrounds that stay readable under white/default text.
const DIFF_DEL: Color = Color::Indexed(52); // dark red
const DIFF_ADD: Color = Color::Indexed(22); // dark green
/// Human-readable rendering for file-mutating tools: `file_path` as header,
/// body as numbered lines (Edit shows old/new as a red/green diff).
/// Returns false when the tool/input isn't one we special-case.
fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<Line<'a>>, width: u16) -> bool {
let Some(path) = input.get("file_path").and_then(Value::as_str) else {
return false;
};
let str_field = |k: &str| input.get(k).and_then(Value::as_str);
match name.to_ascii_lowercase().as_str() {
"write" => {
let Some(content) = str_field("content") else { return false };
out.push(Line::from(vec![
"⚙ Write ".yellow().bold(),
path.to_string().bold(),
]));
push_numbered(out, content, None, width);
true
}
"edit" => {
let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string"))
else {
return false;
};
let mut head = vec!["⚙ Edit ".yellow().bold(), path.to_string().bold()];
if input.get("replace_all").and_then(Value::as_bool) == Some(true) {
head.push(" (replace_all)".dark_gray());
}
out.push(Line::from(head));
push_numbered(out, old, Some(DIFF_DEL), width);
push_numbered(out, new, Some(DIFF_ADD), width);
true
}
_ => false,
}
}
/// Pushes `text` line by line with a line-number gutter. With a `bg`, the
/// whole row (gutter included) is white-on-bg and padded to `width` so the
/// background forms a solid block; without one, the gutter is dark gray.
fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, width: u16) {
let gutter = text.lines().count().max(1).to_string().len();
for (i, l) in text.lines().enumerate() {
let l = sanitize(l);
match bg {
Some(bg) => {
let mut row = format!("{:>gutter$}{l}", i + 1);
let pad = (width as usize).saturating_sub(row.chars().count());
row.extend(std::iter::repeat_n(' ', pad));
out.push(Line::from(Span::styled(
row,
Style::new().bg(bg).fg(color_on(bg)),
)));
}
None => out.push(Line::from(vec![
format!("{:>gutter$}", i + 1).dark_gray(),
Span::raw(l),
])),
}
}
}
/// ratatui renders control chars as zero-width, smearing the layout
/// (tab-indented code was the main offender); expand tabs, drop the rest.
fn sanitize(l: &str) -> String {
let mut s = String::with_capacity(l.len());
for c in l.chars() {
match c {
'\t' => s.push_str(" "),
c if c.is_control() => {}
c => s.push(c),
}
}
s
}
#[cfg(test)]
mod tests {
use super::{base64, color_on, truncate_str, wrap_words};
use ratatui::style::Color;
#[test]
fn color_on_contrasts_with_background() {
// Bright orange / white → black text; dark grey / blue → white text.
assert_eq!(color_on(Color::Indexed(208)), Color::Black);
assert_eq!(color_on(Color::White), Color::Black);
assert_eq!(color_on(Color::Indexed(238)), Color::White);
assert_eq!(color_on(Color::Indexed(17)), Color::White);
assert_eq!(color_on(Color::Rgb(0, 0, 0)), Color::White);
}
#[test]
fn wrap_words_greedy_and_hard_splits() {
assert_eq!(wrap_words("", 10), vec![""]);
assert_eq!(wrap_words("a b c", 3), vec!["a b", "c"]);
// an over-long word is hard-split at the width boundary
assert_eq!(wrap_words("abcdef", 2), vec!["ab", "cd", "ef"]);
}
#[test]
fn truncate_str_adds_ellipsis() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello", 3), "he…");
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn base64_matches_rfc4648_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
}