2533 lines
104 KiB
Rust
2533 lines
104 KiB
Rust
use crate::app::{
|
||
AgentPopup, App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Session, SharedApp,
|
||
ToolResult, filter_index, fmt_tokens,
|
||
};
|
||
use crate::term::{EmbeddedTerm, PaneView};
|
||
use ratatui::Frame;
|
||
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 ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
|
||
use serde_json::Value;
|
||
use std::collections::{HashMap, 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>,
|
||
/// Currently-applied compact-pane inner height, smoothed with hysteresis
|
||
/// (`compact_height`): grows immediately, shrinks only after the smaller
|
||
/// value has held for `SHRINK_DELAY`. Remeasuring Claude Code's live screen
|
||
/// every frame otherwise oscillates during subagent turns / `@`/`/` menu
|
||
/// filtering, and each change resizes the PTY → Ink repaints → flicker.
|
||
compact_inner: u16,
|
||
/// A pending shrink target and when it was first seen; adopted once it has
|
||
/// been stable for `SHRINK_DELAY`. Reset whenever the pane grows or the
|
||
/// measured height matches what's applied.
|
||
shrink_pending: Option<(u16, Instant)>,
|
||
}
|
||
|
||
/// How long a smaller compact-pane height must persist before the pane
|
||
/// actually shrinks. Long enough to ride out the repaint churn of a subagent
|
||
/// turn or a filtering `@`/`/` menu, short enough to feel responsive.
|
||
const SHRINK_DELAY: Duration = Duration::from_millis(400);
|
||
|
||
/// 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())
|
||
}
|
||
|
||
/// Smoothed compact-pane inner height. `measured` is this frame's raw
|
||
/// reading (`None` when the input box couldn't be located — a transient
|
||
/// mid-repaint, so keep what we have). Grows immediately so the prompt
|
||
/// never scrolls out of view, but shrinks only after the smaller height
|
||
/// has held for `SHRINK_DELAY`. Without this the height oscillates every
|
||
/// frame while a subagent streams or an `@`/`/` menu filters, and each
|
||
/// change resizes the PTY, making Claude Code repaint and flicker.
|
||
fn compact_height(&mut self, measured: Option<u16>) -> u16 {
|
||
smooth_compact(&mut self.compact_inner, &mut self.shrink_pending, measured)
|
||
}
|
||
}
|
||
|
||
/// Hysteresis for the compact pane height (see `EmbedUi::compact_height`).
|
||
/// Grows `applied` immediately; commits a smaller `measured` only once that
|
||
/// target has held for `SHRINK_DELAY`; keeps `applied` unchanged on `None`.
|
||
/// Pure over its `&mut` state so it can be unit-tested without an `EmbedUi`.
|
||
fn smooth_compact(
|
||
applied: &mut u16,
|
||
pending: &mut Option<(u16, Instant)>,
|
||
measured: Option<u16>,
|
||
) -> u16 {
|
||
if let Some(m) = measured {
|
||
if m >= *applied {
|
||
*applied = m;
|
||
*pending = None;
|
||
} else {
|
||
// Want to shrink to `m`; only commit once it has been stable.
|
||
match *pending {
|
||
Some((target, since)) if target == m => {
|
||
if since.elapsed() >= SHRINK_DELAY {
|
||
*applied = m;
|
||
*pending = None;
|
||
}
|
||
}
|
||
// First sight of this smaller target (or the target moved).
|
||
_ => *pending = Some((m, Instant::now())),
|
||
}
|
||
}
|
||
}
|
||
*applied
|
||
}
|
||
|
||
/// 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.
|
||
/// One `FeedCache` per lane ever drawn (the main feed and the popup's agent
|
||
/// feed have different widths and scrolls, so they must not share cached
|
||
/// wrapped heights). Dropped wholesale when the displayed session changes.
|
||
#[derive(Default)]
|
||
struct FeedCaches {
|
||
session: String,
|
||
by_lane: HashMap<LaneId, FeedCache>,
|
||
}
|
||
|
||
impl FeedCaches {
|
||
fn get(&mut self, session: &str, lane: LaneId) -> &mut FeedCache {
|
||
if self.session != session {
|
||
self.session = session.to_string();
|
||
self.by_lane.clear();
|
||
}
|
||
self.by_lane.entry(lane).or_default()
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
/// Which lane this cache renders (the popup is narrower than the feed).
|
||
lane: LaneId,
|
||
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,
|
||
}
|
||
|
||
impl CachedEntry {
|
||
/// Placeholder for an entry belonging to another lane: cache slots stay
|
||
/// aligned with the session's entry indices (an entry never changes lane,
|
||
/// so a placeholder is never re-examined).
|
||
fn blank() -> Self {
|
||
Self {
|
||
fingerprint: (usize::MAX, false, usize::MAX, false, false),
|
||
lines: Vec::new(),
|
||
height: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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()),
|
||
// Request-context metadata the model received (the system prompt is too
|
||
// long to show verbatim, so only its size; tools as a name list). Dim,
|
||
// wrapping is handled by the feed Paragraph.
|
||
Kind::System => lines.push(Line::from(format!("⚙ {}", e.content)).dark_gray()),
|
||
Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).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(sanitize(l)).dark_gray().italic());
|
||
}
|
||
}
|
||
Kind::Text => {
|
||
// Sanitize first (preserving newlines): a literal tab in a code
|
||
// block would otherwise survive as a `\t` cell symbol and shift the
|
||
// terminal cursor to the next tab stop, scattering the line.
|
||
let clean = sanitize_md(&e.content);
|
||
lines.extend(crate::markdown::render(&clean, 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!(" {}", sanitize(l))).cyan());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Kind::Error => {
|
||
for l in e.content.lines() {
|
||
lines.push(Line::from(sanitize(l)).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,
|
||
compact_inner: crate::term::DEFAULT_COMPACT_INNER,
|
||
shrink_pending: 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()) {
|
||
kill_current_embed(eui, app);
|
||
}
|
||
return;
|
||
}
|
||
show_embed_pane(eui, app);
|
||
}
|
||
|
||
/// Tear down the current embedded child (if any): dropping it kills the
|
||
/// process (`EmbeddedTerm::drop`), the learned session id is retired into
|
||
/// `past_embeds` (so a later `--resume` of it skips the liveness guard — we
|
||
/// know it's dead), and every per-pane flag is cleared. The single teardown
|
||
/// path every spawn/replace routes through, so pane identity and the
|
||
/// grow/clear flags can never drift between the four call sites.
|
||
fn kill_current_embed(eui: &mut EmbedUi, app: &SharedApp) {
|
||
if eui.term.take().is_none() {
|
||
return;
|
||
}
|
||
let mut a = app.lock().unwrap();
|
||
if let Some(old) = a.embed_session.take() {
|
||
eui.past_embeds.insert(old);
|
||
}
|
||
a.embed_token = None;
|
||
a.embed_grow = false;
|
||
a.embed_grow_rows = None;
|
||
a.embed_clear_at = None;
|
||
}
|
||
|
||
/// Register a freshly spawned pane as the embedded child: record its token
|
||
/// (so the tap recognises the pane's traffic and learns its real session id),
|
||
/// reset per-pane flags, and store the child. `session` is `Some(uuid)` only
|
||
/// for a resume — where we know the id up front and pre-load its transcript;
|
||
/// a fresh spawn passes `None` and lets the first tagged request bind the id.
|
||
fn bind_new_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, session: Option<&str>) {
|
||
let mut a = app.lock().unwrap();
|
||
a.embed_token = Some(t.pane_token.clone());
|
||
a.embed_session = session.map(str::to_string);
|
||
a.embed_grow = false;
|
||
a.embed_grow_rows = None;
|
||
a.embed_clear_at = None;
|
||
if let Some(key) = session {
|
||
a.select_key(key);
|
||
}
|
||
a.follow = true;
|
||
a.clear_turn_focus();
|
||
drop(a);
|
||
eui.term = Some(t);
|
||
}
|
||
|
||
/// Register a freshly spawned pane *and* switch the feed to it immediately:
|
||
/// create a provisional, empty session row keyed by the spawned `--session-id`
|
||
/// and select it, so a fresh `a` / first spawn clears the feed to the new blank
|
||
/// session at once instead of waiting for the first prompt's traffic to bind
|
||
/// it. If Claude Code later reports a different id, the tap renames this row
|
||
/// onto it (the same provisional-rename path a resume uses), so we never end up
|
||
/// with two rows for the one session.
|
||
fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &str) {
|
||
let sid = t.session_id.clone();
|
||
{
|
||
let mut a = app.lock().unwrap();
|
||
if !a.sessions.iter().any(|s| s.key == sid) {
|
||
a.sessions.push(Session::new(sid.clone(), model.to_string()));
|
||
}
|
||
// Remember the exact `--model` argument: the transcript only records
|
||
// the base model id, so this is the one place a `[1m]` (1M-context)
|
||
// pick can survive into a later resume.
|
||
a.set_spawn_model(&sid, model);
|
||
}
|
||
bind_new_pane(eui, app, t, Some(&sid));
|
||
}
|
||
|
||
/// Show (spawning if needed) the claude pane and give it keyboard focus.
|
||
/// A live child is reused (instant reveal); a dead one is replaced.
|
||
fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
|
||
if eui.term.as_ref().is_some_and(|t| t.exited()) {
|
||
kill_current_embed(eui, app);
|
||
}
|
||
if eui.term.is_none() {
|
||
// Real dimensions are applied on the first draw via resize().
|
||
match EmbeddedTerm::spawn(eui.port, 20, 80, "") {
|
||
Ok(t) => bind_fresh_pane(eui, app, t, ""),
|
||
Err(e) => {
|
||
app.lock().unwrap().status = format!("claude spawn failed: {e}");
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
// Reusing a live child: re-select its session if we've learned it.
|
||
let mut a = app.lock().unwrap();
|
||
if let Some(key) = a.embed_session.clone() {
|
||
a.select_key(&key);
|
||
a.follow = true;
|
||
}
|
||
}
|
||
eui.visible = true;
|
||
eui.claude_focused = true;
|
||
}
|
||
|
||
/// `a` 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 is to
|
||
/// start a brand-new session without resume + /clear.
|
||
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
|
||
kill_current_embed(eui, app);
|
||
match EmbeddedTerm::spawn(eui.port, 20, 80, model) {
|
||
Ok(t) => bind_fresh_pane(eui, app, t, model),
|
||
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`.
|
||
///
|
||
/// The resume carries the session's own model forward (`App::resume_model`,
|
||
/// read from its transcript) instead of falling back to the CLI default.
|
||
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
|
||
kill_current_embed(eui, app);
|
||
let model = app.lock().unwrap().resume_model(session_id);
|
||
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id, &model) {
|
||
Ok(t) => {
|
||
{
|
||
// Pre-fill the feed from the on-disk transcript so it isn't
|
||
// blank until the next turn streams. Always re-read (drop any
|
||
// cached path view) — the resume continues the trunk, which a
|
||
// dead-branch view wouldn't show. The bind below provisionally
|
||
// points `embed_session` at this uuid; if Claude Code reports a
|
||
// different id, the tap rebinds (renaming this row onto it).
|
||
let mut a = app.lock().unwrap();
|
||
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.set_spawn_model(session_id, &model);
|
||
a.status = if model.is_empty() {
|
||
format!("resumed {}", &session_id[..8.min(session_id.len())])
|
||
} else {
|
||
format!(
|
||
"resumed {} on {model}",
|
||
&session_id[..8.min(session_id.len())]
|
||
)
|
||
};
|
||
}
|
||
bind_new_pane(eui, app, t, Some(session_id));
|
||
}
|
||
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 caches = FeedCaches::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 caches))?;
|
||
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).
|
||
// The agent popup is modal, so while it is up the wheel is *its*:
|
||
// it moves the picker highlight or scrolls the agent's feed.
|
||
// Left drag = text selection; the copy happens on release in draw().
|
||
if let Event::Mouse(m) = &ev {
|
||
match m.kind {
|
||
MouseEventKind::ScrollUp => wheel(&app, -1),
|
||
MouseEventKind::ScrollDown => wheel(&app, 1),
|
||
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;
|
||
}
|
||
// Agent popup (`A`): modal, so it takes every key while open —
|
||
// the picker navigates, an agent feed scrolls. Esc steps back one
|
||
// layer (feed → picker → closed), `A`/`q` closes outright.
|
||
if a.agent_popup.is_some() {
|
||
match k.code {
|
||
KeyCode::Up | KeyCode::Char('k') => match a.agent_popup {
|
||
Some(AgentPopup::List(_)) => a.agent_popup_move(-1),
|
||
_ => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col(t, -1);
|
||
}
|
||
},
|
||
KeyCode::Down | KeyCode::Char('j') => match a.agent_popup {
|
||
Some(AgentPopup::List(_)) => a.agent_popup_move(1),
|
||
_ => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col(t, 1);
|
||
}
|
||
},
|
||
KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right | KeyCode::Char('l') => {
|
||
a.agent_popup_enter()
|
||
}
|
||
KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => a.agent_popup_back(),
|
||
KeyCode::Char('A') | KeyCode::Char('q') => a.agent_popup = None,
|
||
// Switch agents without a detour through the picker.
|
||
KeyCode::Char('[') => a.agent_popup_cycle(false),
|
||
KeyCode::Char(']') => a.agent_popup_cycle(true),
|
||
KeyCode::PageUp => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col(t, -20);
|
||
}
|
||
KeyCode::PageDown => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col(t, 20);
|
||
}
|
||
KeyCode::Home | KeyCode::Char('g') => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col_end(t, false);
|
||
}
|
||
KeyCode::End | KeyCode::Char('G') => {
|
||
let t = a.agent_popup_lane();
|
||
a.scroll_col_end(t, true);
|
||
}
|
||
_ => {}
|
||
}
|
||
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),
|
||
// A → the subagent popup: one agent opens straight into its
|
||
// feed, several show the picker first. Subagents are shown
|
||
// nowhere else, so this is also how a finished agent's output
|
||
// is read back.
|
||
KeyCode::Char('A') => a.toggle_agent_popup(),
|
||
KeyCode::PageUp => a.scroll_col(None, -20),
|
||
KeyCode::PageDown => a.scroll_col(None, 20),
|
||
KeyCode::Home | KeyCode::Char('g') => a.scroll_col_end(None, false),
|
||
KeyCode::End | KeyCode::Char('G') => a.scroll_col_end(None, true),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw(
|
||
f: &mut Frame,
|
||
app: &SharedApp,
|
||
eui: &mut EmbedUi,
|
||
selection: &mut Option<Selection>,
|
||
caches: &mut FeedCaches,
|
||
) {
|
||
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 hint/token/effort chrome), so a Compact pane auto-sizes to the
|
||
// input box: it grows as the prompt gains lines or an `@`/`/` menu opens,
|
||
// and shrinks back when idle (term::compact_rows measures it). An
|
||
// Interactive prompt (AskUserQuestion / ExitPlanMode) is grown by the tap:
|
||
// sized from the question's option count when it could estimate it, 75% of
|
||
// the screen as fallback.
|
||
const EMBED_MIN: u16 = 7; // MIN_COMPACT_INNER + 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();
|
||
// The pane stays visible while it holds keyboard focus even if the
|
||
// selection isn't (yet) on its session: its real session id is learned
|
||
// from the first request, and an involuntary selection move (another
|
||
// session's traffic) must never yank focus out of a pane the user is
|
||
// typing in. Intentional navigation still hides it — ctrl-↑ drops focus,
|
||
// after which the selection rule applies and tabbing away hides the pane.
|
||
let show_embed =
|
||
eui.visible && eui.term.is_some() && (selected_is_embed || eui.claude_focused);
|
||
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;
|
||
}
|
||
// Mirror focus into shared state so the off-thread tap can avoid stealing
|
||
// the selection from a pane the user is actively driving.
|
||
a.pane_focused = eui.focused();
|
||
let pane_view = if eui.fullscreen {
|
||
PaneView::Full
|
||
} else if a.embed_grow {
|
||
PaneView::Interactive
|
||
} else {
|
||
PaneView::Compact
|
||
};
|
||
let embed_h = if show_embed {
|
||
let total = f.area().height;
|
||
let cap = total.saturating_sub(6).max(1);
|
||
match pane_view {
|
||
// Whole screen minus the footer and the 1-row Min(1) the feed area
|
||
// keeps (layout below still reserves it).
|
||
PaneView::Full => total.saturating_sub(2),
|
||
// Both cropped views size themselves from what the child actually
|
||
// drew, smoothed by the same hysteresis so the PTY isn't resized
|
||
// (→ Ink repaint → flicker) on every per-frame wobble.
|
||
PaneView::Interactive => {
|
||
// Until the prompt appears on screen (the tap grows the pane a
|
||
// beat early) fall back to the tap's estimate from the tool
|
||
// JSON, then to three quarters of the screen.
|
||
let measured = eui.term.as_ref().and_then(EmbeddedTerm::interactive_rows).or(a
|
||
.embed_grow_rows
|
||
.map(|r| r.saturating_add(3))); // + borders + hint row
|
||
let inner = eui.compact_height(measured);
|
||
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
|
||
}
|
||
PaneView::Compact => {
|
||
let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows);
|
||
let inner = eui.compact_height(measured);
|
||
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), 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();
|
||
// Agent scroll state belongs to the displayed session: a switch drops it
|
||
// (and closes the popup), so a lane id can never inherit another session's
|
||
// offset or, worse, point into another session's lane vec.
|
||
if a.cols_session != sel_key.clone().unwrap_or_default() {
|
||
a.cols_session = sel_key.clone().unwrap_or_default();
|
||
a.lane_cols.clear();
|
||
a.agent_popup = None;
|
||
}
|
||
|
||
// 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.main().model))
|
||
} else {
|
||
format!("{id} · {}", short_model(&s.main().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();
|
||
// ⑂N = subagent transcripts recorded next to this session.
|
||
let meta = if d.agents > 0 {
|
||
format!("{id} · ⑂{}", d.agents)
|
||
} else {
|
||
id
|
||
};
|
||
(
|
||
d.uuid.clone(),
|
||
"· ".dark_gray(),
|
||
d.label.clone(),
|
||
meta,
|
||
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 filters = a.filters;
|
||
// 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();
|
||
// Keep the agent popup pointed at a lane that exists (a session switch or
|
||
// a rebuilt on-disk view can invalidate it). Done before the feed borrow,
|
||
// which freezes `a`.
|
||
a.validate_agent_popup();
|
||
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();
|
||
// Scroll state written back after the feed borrow ends: the main feed keeps
|
||
// using App::scroll/follow, the popup's agent owns an entry in
|
||
// App::lane_cols so it can follow its own tail independently.
|
||
let mut main_col = (a.scroll, a.follow);
|
||
let mut lane_writeback: Option<(LaneId, usize, bool)> = None;
|
||
let agent_popup = a.agent_popup;
|
||
if let Some(s) = feed_session {
|
||
// The main feed always gets the whole area: a subagent never takes
|
||
// space from it (nor interleaves into it — draw_feed filters by lane).
|
||
// Agents live in the popup drawn on top, below.
|
||
let out = draw_feed(
|
||
f,
|
||
caches.get(&s.key, MAIN_LANE),
|
||
&FeedArgs {
|
||
s,
|
||
lane: MAIN_LANE,
|
||
area: right,
|
||
focused: feed_focused && agent_popup.is_none(),
|
||
filters,
|
||
live: feed_live,
|
||
leaf: feed_leaf,
|
||
scroll: main_col.0,
|
||
follow: main_col.1,
|
||
scroll_target,
|
||
prompt_jump,
|
||
title: main_title(s, a.show_sessions),
|
||
minimap: true,
|
||
},
|
||
);
|
||
main_col = (out.scroll, out.follow);
|
||
// The subagent popup: 80% of the feed area, centred, drawn over the
|
||
// main feed. Either the agent picker, or one agent's whole stream.
|
||
match agent_popup {
|
||
None => {}
|
||
Some(AgentPopup::List(sel)) => {
|
||
draw_agent_list(f, popup_rect(right), s, &App::agent_list_of(s), sel);
|
||
}
|
||
// `validate_agent_popup` already dropped a lane this session does
|
||
// not have; the bound check makes any disagreement between it and
|
||
// the session actually rendered here a blank frame instead of a
|
||
// panic (the UI thread holds the mutex the proxy tap needs).
|
||
Some(AgentPopup::Feed(lane)) if (lane as usize) < s.lanes.len() => {
|
||
let area = popup_rect(right);
|
||
// `2/3` in the title: which agent of the session this is, so
|
||
// `[`/`]` has somewhere to walk from.
|
||
let list = App::agent_list_of(s);
|
||
let pos = list
|
||
.iter()
|
||
.position(|&x| x == lane)
|
||
.map(|i| (i + 1, list.len()));
|
||
let (scroll, follow) = a.lane_cols.get(&lane).copied().unwrap_or((0, true));
|
||
f.render_widget(Clear, area);
|
||
let out = draw_feed(
|
||
f,
|
||
caches.get(&s.key, lane),
|
||
&FeedArgs {
|
||
s,
|
||
lane,
|
||
area,
|
||
// The popup is modal: it is where the keys go, so it
|
||
// always reads as focused.
|
||
focused: true,
|
||
filters,
|
||
live: feed_live,
|
||
leaf: feed_leaf,
|
||
scroll,
|
||
follow,
|
||
// A subagent's stream has no user prompts and no turn
|
||
// tree, so neither the minimap nor the turn/prompt
|
||
// jumps apply to it.
|
||
scroll_target: None,
|
||
prompt_jump: None,
|
||
title: lane_title(&s.lanes[lane as usize], pos),
|
||
minimap: false,
|
||
},
|
||
);
|
||
lane_writeback = Some((lane, out.scroll, out.follow));
|
||
}
|
||
Some(AgentPopup::Feed(_)) => {}
|
||
}
|
||
} 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 = main_col.0;
|
||
a.follow = main_col.1;
|
||
if let Some((lane, scroll, follow)) = lane_writeback {
|
||
a.lane_cols.insert(lane, (scroll, 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 (PTY sized exactly,
|
||
// no chrome crop); the compact/interactive pane crops Claude Code
|
||
// chrome, so the PTY gets pad rows to draw what we hide.
|
||
//
|
||
// Only fullscreen ties the PTY to the visible pane. Every cropped
|
||
// view derives its height by measuring what's already on the
|
||
// child's screen, so tying the PTY to that height is a feedback
|
||
// loop — Ink only ever draws as many rows as the PTY reports, so a
|
||
// view that suddenly needs more rows than PTY+pad can never draw
|
||
// them, can never be measured, and stays stuck small.
|
||
//
|
||
// Compact hits this with an `@`/`/` menu or a big paste.
|
||
// Interactive hits it harder: Claude Code *lays out* its
|
||
// AskUserQuestion / ExitPlanMode prompt against the reported rows
|
||
// and switches to a truncated rendering when they are few, so a
|
||
// pane-sized PTY made the child itself hide parts of the prompt —
|
||
// no amount of framing on our side could bring them back. A
|
||
// screen-tall PTY lets Ink draw the prompt in full; the render
|
||
// window still shows only the framed region.
|
||
let pty_rows = if pane_view == PaneView::Full {
|
||
inner.height
|
||
} else {
|
||
f.area().height
|
||
};
|
||
et.resize(pty_rows, inner.width, pane_view != PaneView::Full);
|
||
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
|
||
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 matches!(a.agent_popup, Some(AgentPopup::List(_))) {
|
||
"enter watch agent · j/k move · esc/A close"
|
||
} else if a.agent_popup.is_some() {
|
||
"j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close"
|
||
} 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 · a add session · 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"
|
||
};
|
||
// `A` only matters when the displayed session has agents at all — and it is
|
||
// the *only* way to see one, so the hint leads the footer instead of
|
||
// trailing it, where the long key list gets cut off on narrow terminals.
|
||
// Not while the pane has focus: there every key belongs to the child.
|
||
let n_agents = a.agent_list().len();
|
||
let keys = if a.agent_popup.is_some() || embed_focused || n_agents == 0 {
|
||
keys.to_string()
|
||
} else {
|
||
format!("A agents ({n_agents}) · {keys}")
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Share of the feed area the subagent popup covers, per axis.
|
||
const POPUP_PCT: u32 = 80;
|
||
|
||
/// The subagent popup's rect: `POPUP_PCT` of the *feed* area on both axes,
|
||
/// centred over it. Deliberately not the whole main area — the sessions panel
|
||
/// keeps its half, and the ring of main feed left visible around the popup
|
||
/// says "this is an overlay, not the conversation you were reading". Clamped
|
||
/// to `area`, so a tiny terminal simply gets all of it.
|
||
fn popup_rect(area: Rect) -> Rect {
|
||
let pct = |v: u16, floor: u16| -> u16 {
|
||
let scaled = (u32::from(v) * POPUP_PCT / 100) as u16;
|
||
scaled.max(floor.min(v))
|
||
};
|
||
let (w, h) = (pct(area.width, 24), pct(area.height, 6));
|
||
Rect {
|
||
x: area.x + (area.width.saturating_sub(w)) / 2,
|
||
y: area.y + (area.height.saturating_sub(h)) / 2,
|
||
width: w,
|
||
height: h,
|
||
}
|
||
}
|
||
|
||
/// Title of the main feed: id (unless the sessions panel already shows it),
|
||
/// the main chain's model and its token counters.
|
||
fn main_title(s: &Session, show_sessions: bool) -> String {
|
||
let m = s.main();
|
||
let tokens = format!(
|
||
"{} · in {} · out {} ",
|
||
m.model,
|
||
fmt_tokens(m.input_tokens),
|
||
fmt_tokens(m.output_tokens)
|
||
);
|
||
if show_sessions {
|
||
format!(" {tokens}")
|
||
} else {
|
||
let id: String = s.key.chars().take(8).collect();
|
||
format!(" {id} · {tokens}")
|
||
}
|
||
}
|
||
|
||
/// Title of an agent's popup feed: activity mark, agent type · description,
|
||
/// model, output tokens — plus `2/3` when there are siblings for `[`/`]` to
|
||
/// walk to.
|
||
fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String {
|
||
let mut t = format!(
|
||
" {} {} · {} · out {}",
|
||
lane_mark(l),
|
||
l.title(),
|
||
short_model(&l.model),
|
||
fmt_tokens(l.output_tokens)
|
||
);
|
||
if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) {
|
||
t.push_str(&format!(" · {i}/{n}"));
|
||
}
|
||
t.push(' ');
|
||
t
|
||
}
|
||
|
||
/// Activity mark, the same three-way answer the picker sorts on
|
||
/// (`Lane::running`): running now, known finished, or neither (idle without a
|
||
/// finish signal, or a lane read from disk).
|
||
fn lane_mark(l: &Lane) -> &'static str {
|
||
if l.running() {
|
||
"⟳"
|
||
} else if l.finished() {
|
||
// A `<task-notification>` confirmed the run is over.
|
||
"✓"
|
||
} else {
|
||
"·"
|
||
}
|
||
}
|
||
|
||
/// The popup's agent picker (shown when the session has more than one agent):
|
||
/// two lines per agent — mark + `type · description` over a dimmed
|
||
/// model/tools/tokens row — in `App::agent_list_of` order, so the running ones
|
||
/// come first and read bright while finished ones stay reachable below.
|
||
fn draw_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) {
|
||
f.render_widget(Clear, area);
|
||
let inner_w = area.width.saturating_sub(3) as usize;
|
||
let items: Vec<ListItem> = lanes
|
||
.iter()
|
||
.map(|&i| {
|
||
let l = &s.lanes[i as usize];
|
||
let style = if l.running() {
|
||
Style::new().fg(ACCENT).bold()
|
||
} else {
|
||
Style::new().fg(Color::White)
|
||
};
|
||
let head = format!(" {} {}", lane_mark(l), l.title());
|
||
let meta = format!(
|
||
" {} · {} tools · out {}",
|
||
short_model(&l.model),
|
||
l.tool_calls,
|
||
fmt_tokens(l.output_tokens)
|
||
);
|
||
ListItem::new(vec![
|
||
Line::from(truncate_str(&head, inner_w)).style(style),
|
||
Line::from(truncate_str(&meta, inner_w)).dark_gray(),
|
||
])
|
||
})
|
||
.collect();
|
||
let running = lanes
|
||
.iter()
|
||
.filter(|&&i| s.lanes[i as usize].running())
|
||
.count();
|
||
let mut ls = ListState::default();
|
||
ls.select(Some(sel.min(lanes.len().saturating_sub(1))));
|
||
f.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(format!(" agents · {running} of {} running ", lanes.len()))
|
||
.border_style(Style::new().fg(ACCENT).bold()),
|
||
)
|
||
.highlight_style(Style::new().reversed()),
|
||
area,
|
||
&mut ls,
|
||
);
|
||
}
|
||
|
||
/// Everything one feed needs. Bundled because a feed is drawn from two places
|
||
/// (the main chain, and one agent inside the popup) with only these differing.
|
||
struct FeedArgs<'a> {
|
||
s: &'a Session,
|
||
lane: LaneId,
|
||
area: Rect,
|
||
focused: bool,
|
||
filters: [bool; FILTER_LABELS.len()],
|
||
live: bool,
|
||
leaf: Option<usize>,
|
||
scroll: usize,
|
||
follow: bool,
|
||
/// Pin a turn's first entry to the top (main feed only).
|
||
scroll_target: Option<usize>,
|
||
/// `n`/`N` prompt jump (main feed only).
|
||
prompt_jump: Option<bool>,
|
||
title: String,
|
||
/// Draw the user-prompt minimap on the right border (main feed only).
|
||
minimap: bool,
|
||
}
|
||
|
||
struct FeedOut {
|
||
scroll: usize,
|
||
follow: bool,
|
||
}
|
||
|
||
/// Render one lane of a session as a scrollable feed, returning its clamped
|
||
/// scroll state. Only the entries of `args.lane` are considered — that filter
|
||
/// is what keeps subagents out of the main feed entirely, and what lets the
|
||
/// popup show one agent's stream as its own conversation, following its own
|
||
/// tail.
|
||
fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut {
|
||
let mut new_scroll = args.scroll;
|
||
let mut new_follow = args.follow;
|
||
let s = args.s;
|
||
if args.area.height < 3 || args.area.width < 4 {
|
||
return FeedOut {
|
||
scroll: new_scroll,
|
||
follow: new_follow,
|
||
};
|
||
}
|
||
let feed_width = args.area.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 != args.leaf
|
||
|| cache.live != args.live
|
||
|| cache.lane != args.lane
|
||
{
|
||
cache.session_key = s.key.clone();
|
||
cache.width = feed_width;
|
||
cache.leaf = args.leaf;
|
||
cache.live = args.live;
|
||
cache.lane = args.lane;
|
||
cache.entries.clear();
|
||
}
|
||
for (i, e) in s.entries.iter().enumerate() {
|
||
if e.lane != args.lane {
|
||
if cache.entries.len() <= i {
|
||
cache.entries.push(CachedEntry::blank());
|
||
}
|
||
continue;
|
||
}
|
||
let fp = fingerprint(e, args.focused);
|
||
if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) {
|
||
let lines = entry_lines(e, feed_width, args.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 of this lane and their total height.
|
||
let visible: Vec<usize> = s
|
||
.entries
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, e)| e.lane == args.lane && args.filters[filter_index(&e.kind)])
|
||
.map(|(i, _)| i)
|
||
.collect();
|
||
let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum();
|
||
let height = args.area.height.saturating_sub(2) as usize;
|
||
let max_scroll = total.saturating_sub(height);
|
||
new_scroll = if args.follow {
|
||
max_scroll
|
||
} else {
|
||
args.scroll.min(max_scroll)
|
||
};
|
||
if let Some(target) = args.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) = args.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 > args.scroll)
|
||
} else {
|
||
offsets.iter().rev().copied().find(|&o| o < args.scroll)
|
||
};
|
||
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 border = if args.focused {
|
||
Style::new().fg(ACCENT).bold()
|
||
} else {
|
||
Style::new().dark_gray()
|
||
};
|
||
let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||
f.render_widget(
|
||
p.block(
|
||
Block::bordered()
|
||
.title(args.title.clone())
|
||
.border_style(border),
|
||
)
|
||
// 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)),
|
||
args.area,
|
||
);
|
||
|
||
// 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 && args.area.width >= 2 {
|
||
let col = args.area.x + args.area.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 args.focused {
|
||
Style::new().fg(ACCENT).bold()
|
||
} else {
|
||
Style::new().fg(Color::DarkGray)
|
||
};
|
||
if args.minimap {
|
||
let mut acc = 0usize;
|
||
let buf = f.buffer_mut();
|
||
for &i in &visible {
|
||
if matches!(s.entries[i].kind, Kind::User) {
|
||
let y = args.area.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 args.focused {
|
||
Style::new().fg(ACCENT)
|
||
} else {
|
||
Style::new().fg(Color::Gray)
|
||
};
|
||
let buf = f.buffer_mut();
|
||
for k in 0..thumb {
|
||
let y = args.area.y + 1 + (thumb_top + k) as u16;
|
||
buf[(col, y)].set_symbol("█").set_style(style);
|
||
}
|
||
}
|
||
}
|
||
FeedOut {
|
||
scroll: new_scroll,
|
||
follow: new_follow,
|
||
}
|
||
}
|
||
|
||
/// One wheel notch, `dir` = -1 up / +1 down. The agent popup is modal, so
|
||
/// while it is open the notch is its: the picker takes it as a highlight move,
|
||
/// an agent feed as a scroll. No rect hit-testing — a modal owns the wheel.
|
||
/// Otherwise the main feed scrolls, wherever the pointer sits.
|
||
fn wheel(app: &SharedApp, dir: isize) {
|
||
let mut a = app.lock().unwrap();
|
||
match a.agent_popup {
|
||
Some(AgentPopup::List(_)) => a.agent_popup_move(dir),
|
||
// follow re-engages automatically when draw() clamps the scroll to
|
||
// the bottom.
|
||
_ => {
|
||
let target = a.agent_popup_lane();
|
||
a.scroll_col(target, dir * 3);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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.main().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);
|
||
}
|
||
// Subagent spawn. The child's own stream is never in this feed (it is
|
||
// the `A` popup's), so the main chain shows only what was delegated
|
||
// (type, description, opening line of the prompt) and the report that
|
||
// came back — which is all the parent model ever saw of it.
|
||
"agent" | "task" => {
|
||
let kind = sf("subagent_type").unwrap_or("agent");
|
||
let mut head = vec![
|
||
format!("⚙ Agent({kind}) ").yellow().bold(),
|
||
sf("description").unwrap_or_default().to_string().bold(),
|
||
];
|
||
if input.get("run_in_background").and_then(Value::as_bool) == Some(true) {
|
||
head.push(" (background)".dark_gray());
|
||
}
|
||
out.push(Line::from(head));
|
||
if let Some(prompt) = sf("prompt") {
|
||
out.push(Line::from(format!(" → {}", one_line(prompt))).dark_gray());
|
||
}
|
||
push_result(out, result, Some(20));
|
||
}
|
||
"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(),
|
||
]));
|
||
let gutter = content.lines().count().max(1).to_string().len();
|
||
push_numbered(out, content, None, width, 1, gutter);
|
||
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());
|
||
}
|
||
let start = edit_line_number(path, old, new);
|
||
if let Some(n) = start {
|
||
head.push(format!(" (line {n})").dark_gray());
|
||
}
|
||
out.push(Line::from(head));
|
||
let start = start.unwrap_or(1);
|
||
// Shared gutter width so the old/new blocks line up with each
|
||
// other even when one side has more lines than the other.
|
||
let last_line = old.lines().count().max(new.lines().count()).max(1);
|
||
let gutter = (start + last_line - 1).to_string().len();
|
||
push_numbered(out, old, Some(DIFF_DEL), width, start, gutter);
|
||
push_numbered(out, new, Some(DIFF_ADD), width, start, gutter);
|
||
true
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Best-effort lookup of the 1-based line number where an edit lands in the
|
||
/// file on disk. The tool's own `old_string`/`new_string` are unaware of line
|
||
/// numbers (they're a plain substring replacement), and the `push_numbered`
|
||
/// gutter used to always start both diff sides at 1, which didn't match the
|
||
/// real file at all. Tries `new_string` first: Claude Code executes tools
|
||
/// client-side, not through the proxy, so by render time the edit has
|
||
/// virtually always already landed on disk. Falls back to `old_string`
|
||
/// (pre-edit state) for the rare case the edit hasn't run yet. `None`
|
||
/// (numbering falls back to 1) when the file can't be read or neither string
|
||
/// is found, e.g. a later edit already changed the surrounding text.
|
||
fn edit_line_number(path: &str, old: &str, new: &str) -> Option<usize> {
|
||
let content = std::fs::read_to_string(path).ok()?;
|
||
let offset = content.find(new).or_else(|| content.find(old))?;
|
||
Some(content[..offset].matches('\n').count() + 1)
|
||
}
|
||
|
||
/// Pushes `text` line by line with a line-number gutter, numbered from
|
||
/// `start` (the line it actually occupies in the file, when known — see
|
||
/// [`edit_line_number`] — otherwise 1) and right-aligned to a fixed `gutter`
|
||
/// width so an old/new diff pair lines up even when one side has more lines
|
||
/// than the other. 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,
|
||
start: usize,
|
||
gutter: usize,
|
||
) {
|
||
for (i, l) in text.lines().enumerate() {
|
||
let l = sanitize(l);
|
||
let n = start + i;
|
||
match bg {
|
||
Some(bg) => {
|
||
let mut row = format!("{n:>gutter$} │ {l}");
|
||
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!("{n:>gutter$} │ ").dark_gray(),
|
||
Span::raw(l),
|
||
])),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A literal tab survives ratatui as a `\t` cell symbol that the terminal then
|
||
/// renders by jumping to the next tab stop, desyncing the per-cell cursor and
|
||
/// scattering everything painted after it (other control chars render
|
||
/// zero-width and smear too). For a single visual row: expand tabs, drop the
|
||
/// rest. Tab-indented code is the main offender.
|
||
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
|
||
}
|
||
|
||
/// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block
|
||
/// (markdown, where newlines carry structure). Same tab/control handling: the
|
||
/// content is split into rows downstream, so a stray tab must already be gone.
|
||
fn sanitize_md(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
for c in s.chars() {
|
||
match c {
|
||
'\n' => out.push('\n'),
|
||
'\t' => out.push_str(" "),
|
||
c if c.is_control() => {}
|
||
c => out.push(c),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
SHRINK_DELAY, base64, color_on, entry_lines, popup_rect, sanitize_md, smooth_compact,
|
||
truncate_str, wrap_words,
|
||
};
|
||
use crate::app::{Entry, Kind};
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Color;
|
||
use std::time::Instant;
|
||
|
||
/// The subagent popup covers 80% of the *feed* area, centred — never the
|
||
/// sessions panel, and never so small that a tiny terminal loses the view.
|
||
#[test]
|
||
fn agent_popup_covers_four_fifths_of_the_feed() {
|
||
// Feed area offset inside the screen (sessions panel to its left).
|
||
let feed = Rect::new(40, 1, 60, 30);
|
||
let r = popup_rect(feed);
|
||
assert_eq!((r.width, r.height), (48, 24), "80% on both axes");
|
||
// Centred over the feed, so the main feed shows around every edge.
|
||
assert_eq!(r.x, 40 + 6);
|
||
assert_eq!(r.y, 1 + 3);
|
||
assert!(r.x >= feed.x && r.right() <= feed.right());
|
||
assert!(r.y >= feed.y && r.bottom() <= feed.bottom());
|
||
|
||
// Tiny feed: the popup takes all of it rather than collapsing to a
|
||
// couple of unusable rows.
|
||
let tiny = Rect::new(0, 0, 20, 5);
|
||
let r = popup_rect(tiny);
|
||
assert_eq!((r.width, r.height), (20, 5));
|
||
// A wide terminal must not overflow the percentage arithmetic.
|
||
let wide = popup_rect(Rect::new(0, 0, u16::MAX, 100));
|
||
assert_eq!(wide.width, 52428);
|
||
}
|
||
|
||
/// The compact pane grows on the frame the prompt gets taller, but a
|
||
/// smaller reading is held back until it has been stable for SHRINK_DELAY —
|
||
/// so the per-frame wobble during subagent turns / menu filtering doesn't
|
||
/// resize the PTY (which would make Claude Code repaint and flicker). A
|
||
/// transient `None` reading keeps the last height.
|
||
#[test]
|
||
fn compact_height_grows_fast_shrinks_slow() {
|
||
let mut applied = 7u16;
|
||
let mut pending = None;
|
||
|
||
// Grow: adopted immediately, no pending shrink.
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
|
||
assert!(pending.is_none());
|
||
|
||
// Transient miss: height unchanged.
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, None), 12);
|
||
|
||
// A smaller reading is not applied yet — it only arms a pending shrink.
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
|
||
assert!(pending.is_some());
|
||
|
||
// A bounce back up while shrink is pending cancels it.
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
|
||
assert!(pending.is_none());
|
||
|
||
// Re-arm the shrink, then backdate its timestamp past the delay so the
|
||
// next matching reading commits it (no real sleep needed).
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
|
||
pending = Some((6, Instant::now() - SHRINK_DELAY - std::time::Duration::from_millis(1)));
|
||
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 6);
|
||
assert!(pending.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_md_expands_tabs_keeps_newlines() {
|
||
assert_eq!(sanitize_md("a\tb\nc"), "a b\nc");
|
||
// other control chars (here a CR) are dropped, newlines survive
|
||
assert_eq!(sanitize_md("x\r\ny"), "x\ny");
|
||
}
|
||
|
||
/// A tab-indented code block in an assistant message must not leave literal
|
||
/// tabs (or any other control char) in the rendered cells — a `\t` symbol
|
||
/// reaches the terminal verbatim and shifts the cursor, scattering the row.
|
||
#[test]
|
||
fn feed_text_strips_control_chars() {
|
||
let e = Entry::done(Kind::Text, "```\n\tif self.queued:\n\t\treturn\n```".into());
|
||
let lines = entry_lines(&e, 60, false);
|
||
for line in &lines {
|
||
for span in &line.spans {
|
||
assert!(
|
||
!span.content.chars().any(|c| c.is_control()),
|
||
"rendered span still contains a control char: {:?}",
|
||
span.content
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|