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