Initial commit: pass-through proxy TUI for Claude Code streams
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"worktree": {
|
||||||
|
"bgIsolation": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal 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
2446
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
62
README.md
Normal 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
369
src/app.rs
Normal 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
38
src/main.rs
Normal 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
129
src/proxy.rs
Normal 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
78
src/sse.rs
Normal 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
507
src/ui.rs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user