less filter
This commit is contained in:
34
CLAUDE.md
34
CLAUDE.md
@@ -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.
|
||||
|
||||
278
src/app.rs
278
src/app.rs
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
75
src/ui.rs
75
src/ui.rs
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user