Initial commit: pass-through proxy TUI for Claude Code streams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonas H
2026-06-11 07:59:05 +02:00
commit 3f49e11b21
11 changed files with 3711 additions and 0 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"worktree": {
"bgIsolation": "none"
}
}

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

61
CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# claude-thinking
TUI that displays Claude Code's API streams token-by-token (thinking, text, tool
calls) by acting as a pass-through proxy: Claude Code points `ANTHROPIC_BASE_URL`
at `127.0.0.1:8484`, we forward everything verbatim to `api.anthropic.com` and
tee SSE responses into the UI. **Never issue API requests of our own** — zero
extra usage is the core constraint of this project.
## Architecture
```
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
src/proxy.rs axum fallback handler: buffers request body (for session metadata),
forwards via reqwest, streams response back unbuffered, tees SSE
src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8
src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
translates SSE events → session Entries (Drop closes it out)
src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
```
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
UI thread redraws on its own tick (no channel; just the mutex).
## Key invariants
- **Latency-neutral pass-through**: response bytes are forwarded as-is, never
buffered or rewritten. Auth headers pass through untouched. If the tap code
panics or misparses, the proxy must still relay bytes (tee is best-effort).
- **`accept-encoding` is stripped** from forwarded requests so the upstream
sends identity encoding we can parse in transit. Don't "fix" that.
- Hop-by-hop headers (`content-length`, `transfer-encoding`, etc.) are stripped
both directions; hyper re-frames.
- Sessions are keyed by the UUID after `session_` in request `metadata.user_id`.
Concurrent requests (subagents) share a session but each `Tap` tracks its own
current entry index — entries/sessions are append-only, so indices stay stable.
- Tool input streams as raw JSON fragments; pretty-printed only on
`content_block_stop`. Text re-renders markdown every frame, so partial
markdown self-heals.
## Gotchas
- `tui-markdown` is pinned `=0.3.5`: later versions use `ratatui-core` (0.30
alpha types), incompatible with ratatui 0.29.
- ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count`
(used for follow/auto-scroll).
- reqwest is `default-features = false` + `rustls-tls,stream` — don't enable
compression features (would re-add accept-encoding).
- Bind failures (port in use) only surface in the TUI status bar; check for a
stale `claude-thinking` process holding 8484.
- Testing: SSE parser has unit tests (`cargo test`). For a live pass-through
check: `--headless`, then POST to `127.0.0.1:8484/v1/messages` without auth —
a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a
non-tty.
## Not yet handled (known MVP limits)
- Non-streaming requests pass through untapped (e.g. `count_tokens`).
- Sessions are never pruned; long sessions re-render fully each frame.
- Tool results only appear once the *next* request fires; if the session ends
right after a tool call, that result is never seen. Output is what Claude
Code sends the model (i.e. post-truncation).

2446
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "claude-thinking"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
axum = "0.8"
futures-util = "0.3.32"
ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
tui-markdown = "=0.3.5"

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# claude-thinking
A TUI that displays Claude Code's streams token-by-token — thinking, text, and
tool calls — by sitting as a pass-through proxy between Claude Code and the
Anthropic API.
**No extra usage:** it never issues requests of its own; it observes the SSE
stream of requests Claude Code was already making, forwarding bytes verbatim
and unbuffered.
```
claude ──ANTHROPIC_BASE_URL──▶ claude-thinking (127.0.0.1:8484) ──▶ api.anthropic.com
TUI: live token feed
```
## Usage
```sh
cargo build --release
./target/release/claude-thinking
```
Then point Claude Code at the proxy, either per-shell:
```sh
export ANTHROPIC_BASE_URL=http://127.0.0.1:8484
claude
```
or globally in `~/.claude/settings.json`:
```json
{ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8484" } }
```
Note: with the global setting, Claude Code can't reach the API while the
proxy isn't running.
## Keys
| key | action |
|---|---|
| `q` / `Esc` | quit |
| `Tab` / `Shift-Tab` | switch session |
| `j`/`k`, arrows, PgUp/PgDn | scroll (disables follow) |
| `f` / `G` / `End` | follow live tail |
| `g` / `Home` | jump to top |
## Display
- **✻ thinking** — dim italic, streamed token-by-token
- **text** — rendered as markdown (`tui-markdown`)
- **⚙ tool calls** — input JSON pretty-printed on completion, raw fragments while streaming
- **meta** — message boundaries with model, context size, stop reason, token counts
- **errors** — API/stream errors in red
Sessions are keyed by the Claude Code session ID found in request metadata;
concurrent requests (subagents) tap independently.
`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484).

369
src/app.rs Normal file
View File

@@ -0,0 +1,369 @@
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub type SharedApp = Arc<Mutex<App>>;
/// Display names for the filterable entry kinds, in toggle order.
pub const FILTER_LABELS: [&str; 5] = ["thinking", "text", "tools", "meta", "errors"];
pub struct App {
pub sessions: Vec<Session>,
pub selected: usize,
pub scroll: usize,
pub follow: bool,
pub status: String,
/// Which entry kinds are visible (indices match `FILTER_LABELS`).
pub filters: [bool; FILTER_LABELS.len()],
/// `Some(selected_row)` while the filter popup is open.
pub filter_popup: Option<usize>,
/// Whether the session list panel is expanded.
pub show_sessions: bool,
}
impl App {
pub fn new() -> Self {
Self {
sessions: Vec::new(),
selected: 0,
scroll: 0,
follow: true,
status: "starting proxy…".into(),
filters: [true; FILTER_LABELS.len()],
filter_popup: None,
show_sessions: true,
}
}
}
pub fn filter_index(kind: &Kind) -> usize {
match kind {
Kind::Thinking => 0,
Kind::Text => 1,
Kind::Tool { .. } => 2,
Kind::Meta => 3,
Kind::Error => 4,
}
}
pub struct Session {
pub key: String,
pub model: String,
pub entries: Vec<Entry>,
pub active: usize,
pub input_tokens: u64,
pub output_tokens: u64,
pub last_activity: Instant,
/// tool_use id → entry index, so results arriving in the *next* request
/// body can be attached to the tool entry they belong to.
pub tool_ids: HashMap<String, usize>,
}
#[derive(PartialEq)]
pub enum Kind {
Thinking,
Text,
Tool { name: String },
Meta,
Error,
}
pub struct Entry {
pub kind: Kind,
pub content: String,
pub done: bool,
/// Tool entries only: the tool_result echoed back in the next request.
pub result: Option<ToolResult>,
}
pub struct ToolResult {
pub content: String,
pub is_error: bool,
}
impl Entry {
fn meta(content: String) -> Self {
Self { kind: Kind::Meta, content, done: true, result: None }
}
}
/// Scan a request body's `messages` for `tool_result` blocks and attach them
/// to the tool_use entries recorded in `Session::tool_ids`. Purely passive:
/// only reads bytes that were already flowing through the proxy.
pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
return;
};
let mut a = app.lock().unwrap();
let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else {
return;
};
for m in messages {
let Some(blocks) = m.get("content").and_then(Value::as_array) else {
continue;
};
for b in blocks {
if b.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
let Some(idx) = s.tool_ids.remove(id) else {
continue;
};
if let Some(e) = s.entries.get_mut(idx) {
e.result = Some(ToolResult {
content: flatten_result_content(b.get("content")),
is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false),
});
}
}
}
}
/// tool_result content is either a plain string or an array of content blocks.
fn flatten_result_content(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.map(|b| match b.get("type").and_then(Value::as_str) {
Some("text") => b
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
other => format!("[{}]", other.unwrap_or("?")),
})
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
/// One in-flight API request being observed. Created when a streaming
/// /v1/messages request passes through the proxy; handles its SSE events.
pub struct Tap {
app: SharedApp,
sidx: usize,
cur: Option<usize>,
}
impl Tap {
pub fn new(app: SharedApp, key: String, model: String) -> Self {
let sidx = {
let mut a = app.lock().unwrap();
let sidx = match a.sessions.iter().position(|s| s.key == key) {
Some(i) => i,
None => {
a.sessions.push(Session {
key,
model: model.clone(),
entries: Vec::new(),
active: 0,
input_tokens: 0,
output_tokens: 0,
last_activity: Instant::now(),
tool_ids: HashMap::new(),
});
a.sessions.len() - 1
}
};
a.sessions[sidx].active += 1;
a.sessions[sidx].last_activity = Instant::now();
sidx
};
Self { app, sidx, cur: None }
}
pub fn handle(&mut self, ev: &str, d: &Value) {
let mut a = self.app.lock().unwrap();
let s = &mut a.sessions[self.sidx];
s.last_activity = Instant::now();
match ev {
"message_start" => {
if let Some(m) = d.pointer("/message/model").and_then(Value::as_str) {
s.model = m.to_string();
}
let u = |k: &str| {
d.pointer(&format!("/message/usage/{k}"))
.and_then(Value::as_u64)
.unwrap_or(0)
};
let ctx = u("input_tokens")
+ u("cache_read_input_tokens")
+ u("cache_creation_input_tokens");
s.input_tokens = ctx;
s.entries
.push(Entry::meta(format!("{} · context {}", s.model, fmt_tokens(ctx))));
}
"content_block_start" => {
let bt = d
.pointer("/content_block/type")
.and_then(Value::as_str)
.unwrap_or("");
let kind = match bt {
"thinking" => Kind::Thinking,
"redacted_thinking" => Kind::Thinking,
"text" => Kind::Text,
"tool_use" | "server_tool_use" | "mcp_tool_use" => {
// Remember the id so the result (echoed back in the
// next request body) can find this entry.
if let Some(id) =
d.pointer("/content_block/id").and_then(Value::as_str)
{
s.tool_ids.insert(id.to_string(), s.entries.len());
}
Kind::Tool {
name: d
.pointer("/content_block/name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string(),
}
}
other => {
s.entries.push(Entry::meta(format!("[{other}]")));
self.cur = Some(s.entries.len() - 1);
return;
}
};
let content = if bt == "redacted_thinking" {
"[redacted]".to_string()
} else {
String::new()
};
s.entries.push(Entry { kind, content, done: false, result: None });
self.cur = Some(s.entries.len() - 1);
}
"content_block_delta" => {
if let Some(i) = self.cur {
let text = match d.pointer("/delta/type").and_then(Value::as_str) {
Some("thinking_delta") => d.pointer("/delta/thinking"),
Some("text_delta") => d.pointer("/delta/text"),
Some("input_json_delta") => d.pointer("/delta/partial_json"),
_ => None,
};
if let Some(t) = text.and_then(Value::as_str) {
s.entries[i].content.push_str(t);
}
}
}
"content_block_stop" => {
if let Some(i) = self.cur.take() {
let e = &mut s.entries[i];
e.done = true;
// Tool input arrives as JSON fragments; pretty-print once complete.
if matches!(e.kind, Kind::Tool { .. }) {
if let Ok(v) = serde_json::from_str::<Value>(&e.content) {
if let Ok(p) = serde_json::to_string_pretty(&v) {
e.content = p;
}
}
}
}
}
"message_delta" => {
if let Some(o) = d.pointer("/usage/output_tokens").and_then(Value::as_u64) {
s.output_tokens += o;
}
if let Some(r) = d.pointer("/delta/stop_reason").and_then(Value::as_str) {
s.entries.push(Entry::meta(format!("{r}")));
}
}
"error" => {
let msg = d
.pointer("/error/message")
.and_then(Value::as_str)
.unwrap_or("unknown stream error");
let etype = d
.pointer("/error/type")
.and_then(Value::as_str)
.unwrap_or("error");
s.entries.push(Entry {
kind: Kind::Error,
content: format!("✖ {etype}: {msg}"),
done: true,
result: None,
});
}
_ => {}
}
}
}
impl Drop for Tap {
fn drop(&mut self) {
let mut a = self.app.lock().unwrap();
let s = &mut a.sessions[self.sidx];
s.active = s.active.saturating_sub(1);
if let Some(i) = self.cur.take() {
s.entries[i].done = true;
}
}
}
pub fn fmt_tokens(n: u64) -> String {
if n >= 1000 {
format!("{:.1}k", n as f64 / 1000.0)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_result_attaches_to_entry() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into());
tap.handle(
"content_block_start",
&json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}),
);
tap.handle(
"content_block_delta",
&json!({"delta": {"type": "input_json_delta", "partial_json": "{\"command\":\"ls\"}"}}),
);
tap.handle("content_block_stop", &json!({}));
drop(tap);
// Next request echoes the result back (string and block-array forms).
attach_tool_results(
&app,
"abc",
&json!({"messages": [{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01",
"content": [{"type": "text", "text": "file_a\nfile_b"}],
"is_error": false}
]}]}),
);
let a = app.lock().unwrap();
let e = &a.sessions[0].entries[0];
assert!(matches!(e.kind, Kind::Tool { .. }));
let r = e.result.as_ref().expect("result attached");
assert_eq!(r.content, "file_a\nfile_b");
assert!(!r.is_error);
assert!(a.sessions[0].tool_ids.is_empty(), "id consumed");
}
#[test]
fn unknown_tool_id_is_ignored() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
attach_tool_results(
&app,
"abc",
&json!({"messages": [{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_nope", "content": "x"}
]}]}),
);
assert!(app.lock().unwrap().sessions[0].entries.is_empty());
}
}

38
src/main.rs Normal file
View File

@@ -0,0 +1,38 @@
mod app;
mod proxy;
mod sse;
mod ui;
use app::App;
use std::sync::{Arc, Mutex};
fn main() -> anyhow::Result<()> {
let port: u16 = std::env::var("CT_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8484);
let headless = std::env::args().any(|a| a == "--headless");
let app = Arc::new(Mutex::new(App::new()));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let papp = app.clone();
let proxy_handle = rt.spawn(async move {
if let Err(e) = proxy::run(papp.clone(), port).await {
let mut a = papp.lock().unwrap();
a.status = format!("proxy failed: {e}");
}
});
if headless {
eprintln!("claude-thinking proxy on 127.0.0.1:{port} (headless, ctrl-c to quit)");
rt.block_on(proxy_handle)?;
Ok(())
} else {
let r = ui::run(app);
rt.shutdown_background();
r
}
}

129
src/proxy.rs Normal file
View File

@@ -0,0 +1,129 @@
use crate::app::{attach_tool_results, SharedApp, Tap};
use crate::sse::SseParser;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::Method;
use axum::response::Response;
use axum::Router;
use futures_util::StreamExt;
use serde_json::Value;
const UPSTREAM: &str = "https://api.anthropic.com";
#[derive(Clone)]
struct Ctx {
client: reqwest::Client,
app: SharedApp,
}
pub async fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
let client = reqwest::Client::builder().build()?;
let ctx = Ctx { client, app: app.clone() };
let router = Router::new().fallback(forward).with_state(ctx);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
app.lock().unwrap().status =
format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
axum::serve(listener, router).await?;
Ok(())
}
async fn forward(State(ctx): State<Ctx>, req: Request) -> Response {
let app = ctx.app.clone();
match forward_inner(ctx, req).await {
Ok(r) => r,
Err(e) => {
app.lock().unwrap().status = format!("upstream error: {e}");
Response::builder()
.status(502)
.body(Body::from(format!("claude-thinking proxy error: {e}")))
.unwrap()
}
}
}
async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
let (parts, body) = req.into_parts();
// Claude Code sends bodies whole; buffering lets us read session metadata.
let body_bytes = axum::body::to_bytes(body, 512 * 1024 * 1024).await?;
let pq = parts
.uri
.path_and_query()
.map(|p| p.as_str().to_owned())
.unwrap_or_else(|| "/".into());
let url = format!("{UPSTREAM}{pq}");
// Identify streaming /v1/messages requests and attach a tap.
let mut tap: Option<Tap> = None;
if parts.method == Method::POST && pq.starts_with("/v1/messages") {
if let Ok(v) = serde_json::from_slice::<Value>(&body_bytes) {
if v.get("stream").and_then(Value::as_bool).unwrap_or(false) {
let model = v
.get("model")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string();
let key = v
.pointer("/metadata/user_id")
.and_then(Value::as_str)
.and_then(|u| u.split("session_").nth(1))
.unwrap_or("unknown")
.to_string();
// Tool results ride along in the request body; surface them
// on the tool entries from the previous turn.
attach_tool_results(&ctx.app, &key, &v);
tap = Some(Tap::new(ctx.app.clone(), key, model));
}
}
}
let mut rb = ctx.client.request(parts.method.clone(), &url);
for (name, value) in parts.headers.iter() {
// hop-by-hop / recomputed headers; accept-encoding stripped so the
// upstream sends an uncompressed stream we can parse in transit.
if matches!(
name.as_str(),
"host" | "content-length" | "transfer-encoding" | "connection"
| "accept-encoding" | "expect"
) {
continue;
}
rb = rb.header(name.clone(), value.clone());
}
let resp = rb.body(body_bytes).send().await?;
let mut builder = Response::builder().status(resp.status().as_u16());
let is_sse = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("text/event-stream"))
.unwrap_or(false);
for (name, value) in resp.headers() {
if matches!(
name.as_str(),
"content-length" | "transfer-encoding" | "connection"
) {
continue;
}
builder = builder.header(name.clone(), value.clone());
}
// Stream the body back unbuffered; tee SSE bytes into the parser.
let body = match (is_sse, tap) {
(true, Some(mut tap)) => {
let mut parser = SseParser::default();
let stream = resp.bytes_stream().map(move |chunk| {
if let Ok(b) = &chunk {
for (ev, data) in parser.feed(b) {
tap.handle(&ev, &data);
}
}
chunk
});
Body::from_stream(stream)
}
_ => Body::from_stream(resp.bytes_stream()),
};
Ok(builder.body(body)?)
}

78
src/sse.rs Normal file
View File

@@ -0,0 +1,78 @@
use serde_json::Value;
/// Incremental SSE parser. Fed raw bytes as they pass through the proxy,
/// yields complete (event_name, parsed_data) pairs.
#[derive(Default)]
pub struct SseParser {
buf: Vec<u8>,
}
impl SseParser {
pub fn feed(&mut self, chunk: &[u8]) -> Vec<(String, Value)> {
self.buf.extend_from_slice(chunk);
let mut out = Vec::new();
while let Some((content_end, drain_end)) = find_event_end(&self.buf) {
let event: Vec<u8> = self.buf.drain(..drain_end).collect();
let text = String::from_utf8_lossy(&event[..content_end]);
let mut name = String::new();
let mut data = String::new();
for line in text.lines() {
if let Some(v) = line.strip_prefix("event:") {
name = v.trim().to_string();
} else if let Some(v) = line.strip_prefix("data:") {
data.push_str(v.strip_prefix(' ').unwrap_or(v));
}
}
if !data.is_empty() {
if let Ok(v) = serde_json::from_str(&data) {
out.push((name, v));
}
}
}
out
}
}
/// Find the end of the next complete SSE event ("\n\n" or "\r\n\r\n").
/// Returns (content_end, drain_end).
fn find_event_end(buf: &[u8]) -> Option<(usize, usize)> {
let mut i = 0;
while i + 1 < buf.len() {
if buf[i] == b'\n' && buf[i + 1] == b'\n' {
return Some((i, i + 2));
}
if buf[i] == b'\r' && i + 3 < buf.len() && &buf[i..i + 4] == b"\r\n\r\n" {
return Some((i, i + 4));
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_split_events() {
let mut p = SseParser::default();
let ev = p.feed(b"event: content_block_delta\ndata: {\"delta\":{\"type\":\"thinking_del");
assert!(ev.is_empty());
let ev = p.feed(b"ta\",\"thinking\":\" hello\"}}\n\nevent: ping\ndata: {}\n\n");
assert_eq!(ev.len(), 2);
assert_eq!(ev[0].0, "content_block_delta");
assert_eq!(
ev[0].1.pointer("/delta/thinking").unwrap().as_str().unwrap(),
" hello"
);
assert_eq!(ev[1].0, "ping");
}
#[test]
fn parses_crlf_events() {
let mut p = SseParser::default();
let ev = p.feed(b"event: message_stop\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n");
assert_eq!(ev.len(), 1);
assert_eq!(ev[0].0, "message_stop");
}
}

507
src/ui.rs Normal file
View File

@@ -0,0 +1,507 @@
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
}