Files
claude-cloak/src/ui.rs
2026-06-11 07:59:05 +02:00

508 lines
19 KiB
Rust

use crate::app::{filter_index, fmt_tokens, Kind, SharedApp, ToolResult, FILTER_LABELS};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, 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::time::Duration;
pub fn run(app: SharedApp) -> anyhow::Result<()> {
let mut terminal = ratatui::init();
let res = event_loop(&mut terminal, app);
ratatui::restore();
res
}
fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: SharedApp) -> anyhow::Result<()> {
loop {
terminal.draw(|f| draw(f, &app))?;
if !event::poll(Duration::from_millis(33))? {
continue;
}
if let Event::Key(k) = event::read()? {
if k.kind != KeyEventKind::Press {
continue;
}
let mut a = app.lock().unwrap();
let nsess = a.sessions.len();
if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) {
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;
}
match k.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('f') => a.filter_popup = Some(0),
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
KeyCode::Tab if nsess > 0 => {
a.selected = (a.selected + 1) % nsess;
a.follow = true;
}
KeyCode::BackTab if nsess > 0 => {
a.selected = (a.selected + nsess - 1) % nsess;
a.follow = true;
}
KeyCode::Up | KeyCode::Char('k') => {
a.follow = false;
a.scroll = a.scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
a.follow = false;
a.scroll += 1;
}
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) {
let mut a = app.lock().unwrap();
let [main, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
.areas(f.area());
let left_width = if a.show_sessions { 26 } else { 0 };
let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)])
.areas(main);
let sel = a.selected.min(a.sessions.len().saturating_sub(1));
a.selected = sel;
// Session list (folded away when toggled off)
if a.show_sessions {
let items: Vec<ListItem> = a
.sessions
.iter()
.map(|s| {
let dot = if s.active > 0 { "".green() } else { "".dark_gray() };
let id: String = s.key.chars().take(8).collect();
ListItem::new(Line::from(vec![
dot,
" ".into(),
id.into(),
" ".into(),
short_model(&s.model).cyan(),
]))
})
.collect();
let mut ls = ListState::default();
if !a.sessions.is_empty() {
ls.select(Some(sel));
}
f.render_stateful_widget(
List::new(items)
.block(Block::bordered().title(" sessions "))
.highlight_style(ratatui::style::Style::new().reversed()),
left,
&mut ls,
);
}
// Feed
let follow = a.follow;
let scroll0 = a.scroll;
let filters = a.filters;
let mut new_scroll = scroll0;
let mut new_follow = follow;
if let Some(s) = a.sessions.get(sel) {
let feed_width = right.width.saturating_sub(2);
let mut lines: Vec<Line> = Vec::new();
for e in &s.entries {
if !filters[filter_index(&e.kind)] {
continue;
}
match &e.kind {
Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()),
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(tui_markdown::from_str(&e.content).lines);
}
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, feed_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());
}
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 });
let width = right.width.saturating_sub(2);
let height = right.height.saturating_sub(2) as usize;
let max_scroll = p.line_count(width).saturating_sub(height);
new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) };
// Reaching the bottom re-engages follow automatically.
if new_scroll >= max_scroll {
new_follow = true;
}
f.render_widget(
p.block(Block::bordered().title(title))
.scroll((new_scroll.min(u16::MAX as usize) as u16, 0)),
right,
);
} else {
f.render_widget(
Paragraph::new(
"\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:8484",
)
.dark_gray()
.block(Block::bordered()),
right,
);
}
a.scroll = new_scroll;
a.follow = new_follow;
let keys = if a.filter_popup.is_some() {
"space toggle · j/k move · f/esc close"
} else {
"q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom"
};
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,
);
}
}
fn short_model(m: &str) -> String {
m.strip_prefix("claude-").unwrap_or(m).to_string()
}
/// 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 clipped: String = first.chars().take(MAX).collect();
if clipped.len() < s.len() {
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(' ').take(pad));
out.push(Line::from(Span::styled(
row,
Style::new().bg(bg).fg(Color::White),
)));
}
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
}