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