508 lines
19 KiB
Rust
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
|
|
}
|