less filter

This commit is contained in:
Jonas H
2026-06-19 12:14:31 +02:00
parent f0e5c202f5
commit f97828d7d1
4 changed files with 344 additions and 45 deletions

View File

@@ -12,14 +12,26 @@ extra usage is the core constraint of this project.
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,
tool results, and user prompts — `app::record_user_prompt` lifts
the trailing user message into a Kind::User feed entry;
`app::extract_user_text` splits a user text block into its
injected `<system-reminder>` spans (kept as dimmed Kind::Reminder
entries, never discarded — the prompt survives even when it
shares its block with a reminder, the first-message-after-resume
case) and the real prompt; `strip_injected` is the prompt-only
projection used for turn-tree labels), forwards via reqwest,
streams the response back unbuffered, tees SSE
the trailing user message into a Kind::User feed entry verbatim
(incl. slash-command machinery — the goal is to show everything
the model received, never filter it). On a turn-starting request
(tools present) it also emits the system-prompt *size* as a
Kind::System line (the prompt itself is too long to show) and the
available tool set as Kind::ToolDefs — each once, re-emitted only
on change (system/tools/history are re-sent every request but are
not new data). A *side* request (no tools — topic/title haiku
calls) is still shown, tagged with a `── side request ──` Meta
divider. `app::extract_user_text` splits a user text block into
its injected `<system-reminder>` spans (kept as dimmed
Kind::Reminder entries, never discarded — the prompt survives even
when it shares its block with a reminder, the
first-message-after-resume case) and the real prompt;
`strip_injected` is the label-only projection (drops reminders
*and* slash-command machinery) used for turn-tree labels.
Dedup drops only true resends (the just-recorded prompt is still
the tail entry), so verbatim repeats in later turns survive.
Forwards via reqwest, streams the 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)
@@ -37,8 +49,10 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
blocks. `color_on(bg)` picks black/white text by background
luminance (used by the prompt blocks and the edit/diff blocks) so
filled blocks stay legible under any terminal theme. Injected
`<system-reminder>`s show as dim `Kind::Reminder` entries
(filter "system"). The feed's right border doubles as a prompt
`<system-reminder>`s and the system-prompt-size `Kind::System`
line show dim under the "system" filter; the `Kind::ToolDefs`
tool-list line shares the "tools" filter with tool calls. The
feed's right border doubles as a prompt
minimap: `*` markers show where each user message sits in the
whole conversation, with the scroll thumb drawn on top where they
coincide.

View File

@@ -415,10 +415,12 @@ pub fn filter_index(kind: &Kind) -> usize {
Kind::User => 0,
Kind::Thinking => 1,
Kind::Text => 2,
Kind::Tool { .. } => 3,
// Tool calls and the available-tool list share the "tools" toggle.
Kind::Tool { .. } | Kind::ToolDefs => 3,
Kind::Meta => 4,
Kind::Error => 5,
Kind::Reminder => 6,
// Reminders and the system-prompt-size line share the "system" toggle.
Kind::Reminder | Kind::System => 6,
}
}
@@ -433,6 +435,13 @@ pub struct Session {
/// 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>,
/// Char length of the system prompt last surfaced as a `Kind::System`
/// entry. The system prompt is re-sent verbatim on every request, so it is
/// emitted once (and again only if the count changes) — never duplicated.
pub last_system_len: Option<usize>,
/// Signature (joined tool names) of the tool set last surfaced as a
/// `Kind::ToolDefs` entry; re-emitted only when the available tools change.
pub last_tools_sig: Option<String>,
}
impl Session {
@@ -446,6 +455,8 @@ impl Session {
output_tokens: 0,
last_activity: Instant::now(),
tool_ids: HashMap::new(),
last_system_len: None,
last_tools_sig: None,
}
}
}
@@ -464,6 +475,14 @@ pub enum Kind {
/// dimmed, like a thinking block) rather than discarded, so the context the
/// model actually received is visible. Filtered under the "system" toggle.
Reminder,
/// The system prompt's character count (the prompt itself is too long to
/// show verbatim). Emitted once per session, re-emitted only on change.
/// Filtered under the "system" toggle.
System,
/// The set of tool definitions the model is handed (names + count, not the
/// full schemas). Emitted once per session, re-emitted only when the tool
/// set changes. Filtered under the "tools" toggle.
ToolDefs,
}
pub struct Entry {
@@ -566,8 +585,19 @@ pub(crate) fn extract_user_text(t: &str) -> (Vec<String>, String) {
}
}
}
// Drop slash-command wrapper lines (these always occupy their own lines).
let prompt = s
// The remainder is kept verbatim: slash-command wrappers
// (<command-name> etc.) are part of what the model actually received, so
// the feed shows them. Only the turn-tree *label* path (`strip_injected`)
// drops them for a clean one-liner.
(reminders, s.trim().to_string())
}
/// The user's typed prompt only — used for turn-tree labels, where injected
/// reminders *and* slash-command machinery are noise. (The feed itself shows
/// everything the model received; this is the label-only projection.)
pub(crate) fn strip_injected(t: &str) -> String {
extract_user_text(t)
.1
.lines()
.filter(|l| {
let lt = l.trim_start();
@@ -579,32 +609,29 @@ pub(crate) fn extract_user_text(t: &str) -> (Vec<String>, String) {
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
(reminders, prompt)
.to_string()
}
/// The user's typed prompt only (reminders discarded) — used where injected
/// context is noise, e.g. turn-tree labels.
pub(crate) fn strip_injected(t: &str) -> String {
extract_user_text(t).1
}
/// Record a user-submitted prompt from a request body as a feed entry, plus any
/// injected `<system-reminder>` blocks that rode along (shown dimmed before the
/// prompt). Purely passive (reads bytes already flowing through the proxy).
/// Record everything new the model received this request as feed entries:
/// the trailing user prompt verbatim (incl. slash-command machinery), any
/// injected `<system-reminder>` blocks (dimmed, before it), and — for a
/// turn-starting request — the system-prompt size and the available tool set
/// (each surfaced once, re-emitted only on change; never re-dumped though the
/// full system/tools/history are re-sent every request). Purely passive (reads
/// bytes already flowing through the proxy).
///
/// Only the *trailing* user message counts: tool-loop continuations end in
/// tool_result blocks and thus contribute no text, so exactly the turn-starting
/// prompt lands here. Side requests (topic detection etc.) carry no `tools` and
/// are skipped; retries/resends are deduped against the last recorded prompt
/// (the dedup gates the reminders too, so a resend doesn't double them up).
/// prompt lands here. Side requests (topic detection, title generation, …) carry
/// no `tools`; their prompt is still shown, tagged with a `── side request ──`
/// divider. Retries/resends are deduped against the last recorded prompt (which
/// gates the reminders/divider too, so a resend doesn't double them up).
pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
if body
// A turn-starting (main) request carries tools; a side request does not.
let has_tools = body
.get("tools")
.and_then(Value::as_array)
.is_none_or(Vec::is_empty)
{
return;
}
.is_some_and(|t| !t.is_empty());
let Some(last) = body
.get("messages")
.and_then(Value::as_array)
@@ -651,14 +678,50 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else {
return;
};
// Dedup only *true resends*: a retry re-fires the same request before any
// response lands, so the just-recorded prompt is still the tail entry
// (its reminders ride directly in front of it). A later turn that merely
// repeats the same text ("yes", "continue", "go on") sits behind the
// previous turn's assistant/tool entries, so it survives. The old check
// scanned back to the most recent User entry and dropped every verbatim
// repeat regardless of intervening activity.
if s.entries
.iter()
.rev()
.find(|e| e.kind == Kind::User)
.is_some_and(|e| e.content == text)
.find(|e| e.kind != Kind::Reminder)
.is_some_and(|e| e.kind == Kind::User && e.content == text)
{
return;
}
if has_tools {
// Surface the system-prompt size and the tool set once, then only when
// they change — they ride along on every request but are not new data.
let sys = system_char_len(body);
if sys > 0 && s.last_system_len != Some(sys) {
s.last_system_len = Some(sys);
s.entries.push(Entry {
kind: Kind::System,
content: format!("system prompt: {} chars", fmt_count(sys)),
done: true,
result: None,
});
}
if let Some((sig, line)) = tools_summary(body)
&& s.last_tools_sig.as_deref() != Some(sig.as_str())
{
s.last_tools_sig = Some(sig);
s.entries.push(Entry {
kind: Kind::ToolDefs,
content: line,
done: true,
result: None,
});
}
} else {
// A background side request (no tools): tag it so its prompt/response
// are not mistaken for part of the main conversation.
s.entries.push(Entry::meta("── side request ──".to_string()));
}
for r in reminders {
s.entries.push(Entry {
kind: Kind::Reminder,
@@ -894,6 +957,51 @@ pub fn fmt_tokens(n: u64) -> String {
}
}
/// Group a count with thousands separators ("12431" → "12,431").
pub fn fmt_count(n: usize) -> String {
let digits = n.to_string();
let bytes = digits.as_bytes();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
/// Total character length of a request's `system` prompt (string form or an
/// array of text blocks). Zero when absent — the model received no system text.
fn system_char_len(body: &Value) -> usize {
match body.get("system") {
Some(Value::String(s)) => s.chars().count(),
Some(Value::Array(blocks)) => blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.map(|t| t.chars().count())
.sum(),
_ => 0,
}
}
/// `(signature, display line)` for a request's tool set, or `None` if it
/// declares no tools. The signature (joined names) drives change detection so
/// the list is surfaced once and re-emitted only when the available tools shift.
fn tools_summary(body: &Value) -> Option<(String, String)> {
let names: Vec<&str> = body
.get("tools")
.and_then(Value::as_array)?
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.collect();
if names.is_empty() {
return None;
}
let line = format!("tools ({}): {}", names.len(), names.join(", "));
Some((names.join(","), line))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -992,19 +1100,135 @@ mod tests {
]}
]}),
);
// Side request without tools (topic detection etc.) → no entry.
// Side request without tools (topic detection etc.) → still surfaced,
// tagged with a `── side request ──` divider before its prompt.
record_user_prompt(
&app,
"abc",
&json!({"messages": [{"role": "user", "content": "fresh prompt"}]}),
);
let a = app.lock().unwrap();
let users: Vec<_> = a.sessions[0]
.entries
.iter()
.filter(|e| e.kind == Kind::User)
.collect();
assert_eq!(users.len(), 2, "the side-request prompt is shown too");
assert_eq!(users[1].content, "fresh prompt");
assert!(
a.sessions[0]
.entries
.iter()
.any(|e| e.kind == Kind::Meta && e.content.contains("side request")),
"the side request is tagged"
);
}
#[test]
fn repeated_prompt_across_turns_is_kept() {
// A verbatim repeat ("continue") in a *later* turn must show — only an
// immediate resend (same request, nothing streamed since) is deduped.
let app: SharedApp = Arc::new(Mutex::new(App::new()));
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
let body = json!({"tools": [{"name": "Bash"}], "messages": [
{"role": "user", "content": "continue"}
]});
record_user_prompt(&app, "abc", &body);
record_user_prompt(&app, "abc", &body); // immediate resend → deduped
// Simulate the turn producing a response between the two prompts.
{
let mut a = app.lock().unwrap();
a.sessions[0].entries.push(Entry {
kind: Kind::Text,
content: "ok, continuing".into(),
done: true,
result: None,
});
}
record_user_prompt(&app, "abc", &body); // new turn, same text → kept
let a = app.lock().unwrap();
assert_eq!(
a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(),
1
2
);
}
#[test]
fn system_size_and_tools_surfaced_once_then_on_change() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
let turn = |sys: &str, tools: Value, prompt: &str| {
json!({"system": sys, "tools": tools, "messages": [
{"role": "user", "content": prompt}
]})
};
let tools = json!([{"name": "Bash"}, {"name": "Read"}]);
// First turn: system size + tool list + prompt.
record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "one"));
// Second turn, same system + tools: only the new prompt.
record_user_prompt(&app, "abc", &turn("0123456789", tools.clone(), "two"));
// Third turn, tools changed: re-emit the tool list (system unchanged).
record_user_prompt(
&app,
"abc",
&turn("0123456789", json!([{"name": "Bash"}]), "three"),
);
let a = app.lock().unwrap();
let kinds: Vec<&Kind> = a.sessions[0].entries.iter().map(|e| &e.kind).collect();
assert_eq!(
a.sessions[0].entries.iter().filter(|e| e.kind == Kind::System).count(),
1,
"system size emitted once (it never changed)"
);
assert_eq!(
a.sessions[0].entries.iter().filter(|e| e.kind == Kind::ToolDefs).count(),
2,
"tool list re-emitted when the set changed"
);
let sys = a.sessions[0]
.entries
.iter()
.find(|e| e.kind == Kind::System)
.unwrap();
assert_eq!(sys.content, "system prompt: 10 chars");
let tdef = a.sessions[0]
.entries
.iter()
.find(|e| e.kind == Kind::ToolDefs)
.unwrap();
assert_eq!(tdef.content, "tools (2): Bash, Read");
// Order on the first turn: system, tools, then the prompt.
assert!(matches!(kinds[0], Kind::System));
assert!(matches!(kinds[1], Kind::ToolDefs));
assert!(matches!(kinds[2], Kind::User));
}
#[test]
fn slash_command_machinery_shown_in_feed_but_stripped_in_label() {
// The model receives the slash-command wrappers, so the feed keeps them
// verbatim; only the turn-tree label projection drops them.
let raw = "<command-name>/commit</command-name>\n<command-args>-a</command-args>\nthe rest";
let app: SharedApp = Arc::new(Mutex::new(App::new()));
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
record_user_prompt(
&app,
"abc",
&json!({"tools": [{"name": "Bash"}], "messages": [
{"role": "user", "content": raw}
]}),
);
let a = app.lock().unwrap();
let user = a.sessions[0]
.entries
.iter()
.find(|e| e.kind == Kind::User)
.unwrap();
assert!(user.content.contains("<command-name>"), "feed keeps machinery");
assert_eq!(strip_injected(raw), "the rest", "label drops machinery");
}
#[test]
fn inline_system_reminder_is_stripped_not_dropped() {
// The first message after a resume often arrives with a

View File

@@ -405,6 +405,8 @@ impl EntryParser {
output_tokens: 0,
last_activity: std::time::Instant::now(),
tool_ids: HashMap::new(),
last_system_len: None,
last_tools_sig: None,
},
leaf,
turn_entries,

View File

@@ -195,6 +195,11 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
match &e.kind {
Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()),
// Request-context metadata the model received (the system prompt is too
// long to show verbatim, so only its size; tools as a name list). Dim,
// wrapping is handled by the feed Paragraph.
Kind::System => lines.push(Line::from(format!("{}", e.content)).dark_gray()),
Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).dark_gray()),
Kind::User => {
let style = user_block_style(focused);
let w = (width as usize).max(1);
@@ -229,11 +234,15 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
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());
lines.push(Line::from(sanitize(l)).dark_gray().italic());
}
}
Kind::Text => {
lines.extend(crate::markdown::render(&e.content, width).into_iter().map(own_line));
// Sanitize first (preserving newlines): a literal tab in a code
// block would otherwise survive as a `\t` cell symbol and shift the
// terminal cursor to the next tab stop, scattering the line.
let clean = sanitize_md(&e.content);
lines.extend(crate::markdown::render(&clean, width).into_iter().map(own_line));
}
Kind::Tool { name } => {
// Once the input JSON is complete, every tool gets a
@@ -253,14 +262,14 @@ fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
};
lines.push(Line::from(head).yellow().bold());
for l in e.content.lines() {
lines.push(Line::from(format!(" {l}")).cyan());
lines.push(Line::from(format!(" {}", sanitize(l))).cyan());
}
}
}
}
Kind::Error => {
for l in e.content.lines() {
lines.push(Line::from(l.to_string()).red().bold());
lines.push(Line::from(sanitize(l)).red().bold());
}
}
}
@@ -1318,7 +1327,7 @@ fn draw(
} else if a.on_turns() {
"j/k turns · v visual · b branch · ←/space close · wheel scrolls feed"
} else if show_embed {
"ctrl-↓ claude · n new · q quit · j/k move · space tree · f filter · F2 hide"
"ctrl-↓ claude · a add session · q quit · j/k move · space tree · f filter · F2 hide"
} else {
"q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach"
};
@@ -1800,8 +1809,11 @@ fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, wid
}
}
/// ratatui renders control chars as zero-width, smearing the layout
/// (tab-indented code was the main offender); expand tabs, drop the rest.
/// A literal tab survives ratatui as a `\t` cell symbol that the terminal then
/// renders by jumping to the next tab stop, desyncing the per-cell cursor and
/// scattering everything painted after it (other control chars render
/// zero-width and smear too). For a single visual row: expand tabs, drop the
/// rest. Tab-indented code is the main offender.
fn sanitize(l: &str) -> String {
let mut s = String::with_capacity(l.len());
for c in l.chars() {
@@ -1814,11 +1826,58 @@ fn sanitize(l: &str) -> String {
s
}
/// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block
/// (markdown, where newlines carry structure). Same tab/control handling: the
/// content is split into rows downstream, so a stray tab must already be gone.
fn sanitize_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push('\n'),
'\t' => out.push_str(" "),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{base64, color_on, truncate_str, wrap_words};
use super::{base64, color_on, entry_lines, sanitize_md, truncate_str, wrap_words};
use crate::app::{Entry, Kind};
use ratatui::style::Color;
#[test]
fn sanitize_md_expands_tabs_keeps_newlines() {
assert_eq!(sanitize_md("a\tb\nc"), "a b\nc");
// other control chars (here a CR) are dropped, newlines survive
assert_eq!(sanitize_md("x\r\ny"), "x\ny");
}
/// A tab-indented code block in an assistant message must not leave literal
/// tabs (or any other control char) in the rendered cells — a `\t` symbol
/// reaches the terminal verbatim and shifts the cursor, scattering the row.
#[test]
fn feed_text_strips_control_chars() {
let e = Entry {
kind: Kind::Text,
content: "```\n\tif self.queued:\n\t\treturn\n```".into(),
done: true,
result: None,
};
let lines = entry_lines(&e, 60, false);
for line in &lines {
for span in &line.spans {
assert!(
!span.content.chars().any(|c| c.is_control()),
"rendered span still contains a control char: {:?}",
span.content
);
}
}
}
#[test]
fn color_on_contrasts_with_background() {
// Bright orange / white → black text; dark grey / blue → white text.