UX improvements
This commit is contained in:
195
src/app.rs
195
src/app.rs
@@ -13,7 +13,8 @@ pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> {
|
||||
}
|
||||
|
||||
/// Display names for the filterable entry kinds, in toggle order.
|
||||
pub const FILTER_LABELS: [&str; 6] = ["user", "thinking", "text", "tools", "meta", "errors"];
|
||||
pub const FILTER_LABELS: [&str; 7] =
|
||||
["user", "thinking", "text", "tools", "meta", "errors", "system"];
|
||||
|
||||
/// Fallback model picker entries used until (or unless) `term::discover_models`
|
||||
/// reads the live alias set out of the installed `claude` binary. Each entry is
|
||||
@@ -66,6 +67,11 @@ pub struct App {
|
||||
/// the highlighted turn's first entry (heights live in the render cache,
|
||||
/// so the key handler can't compute the offset itself).
|
||||
pub turn_dirty: bool,
|
||||
/// Request to jump the feed scroll to the next (`Some(true)`) or previous
|
||||
/// (`Some(false)`) `Kind::User` prompt — `n`/`N`. Honoured once on the next
|
||||
/// draw (entry heights live in the render cache, so the key handler can't
|
||||
/// compute the offset itself), then cleared.
|
||||
pub prompt_jump: Option<bool>,
|
||||
/// Session UUID of the embedded `claude` pane (src/term.rs), so the tap
|
||||
/// can recognise traffic belonging to it.
|
||||
///
|
||||
@@ -147,6 +153,7 @@ impl App {
|
||||
history: HashMap::new(),
|
||||
expanded: None,
|
||||
turn_dirty: false,
|
||||
prompt_jump: None,
|
||||
embed_session: None,
|
||||
embed_grow: false,
|
||||
embed_grow_rows: None,
|
||||
@@ -411,6 +418,7 @@ pub fn filter_index(kind: &Kind) -> usize {
|
||||
Kind::Tool { .. } => 3,
|
||||
Kind::Meta => 4,
|
||||
Kind::Error => 5,
|
||||
Kind::Reminder => 6,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +460,10 @@ pub enum Kind {
|
||||
Tool { name: String },
|
||||
Meta,
|
||||
Error,
|
||||
/// A `<system-reminder>` Claude Code injects into a user turn. Kept (shown
|
||||
/// dimmed, like a thinking block) rather than discarded, so the context the
|
||||
/// model actually received is visible. Filtered under the "system" toggle.
|
||||
Reminder,
|
||||
}
|
||||
|
||||
pub struct Entry {
|
||||
@@ -519,21 +531,72 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text blocks Claude Code injects around the user's words; not what the
|
||||
/// user typed, so they don't belong in a "user prompt" entry.
|
||||
pub(crate) fn is_injected_block(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
t.starts_with("<system-reminder>")
|
||||
|| t.starts_with("<command-name>")
|
||||
|| t.starts_with("<local-command")
|
||||
/// Split a user text block into its injected `<system-reminder>` spans (kept so
|
||||
/// the UI can show them dimmed) and the real prompt the user typed.
|
||||
///
|
||||
/// Crucially, a reminder is sometimes prepended *inside the same text block* as
|
||||
/// the prompt (this is what swallowed the first message after a resume), so the
|
||||
/// spans are excised in place and the remainder kept — never the whole block
|
||||
/// dropped on a leading tag. Slash-command wrappers are removed outright (pure
|
||||
/// machinery, nothing the user typed).
|
||||
pub(crate) fn extract_user_text(t: &str) -> (Vec<String>, String) {
|
||||
const OPEN: &str = "<system-reminder>";
|
||||
const CLOSE: &str = "</system-reminder>";
|
||||
let mut reminders = Vec::new();
|
||||
let mut s = t.to_string();
|
||||
while let Some(start) = s.find(OPEN) {
|
||||
let after = start + OPEN.len();
|
||||
match s[after..].find(CLOSE) {
|
||||
Some(rel) => {
|
||||
let inner = s[after..after + rel].trim();
|
||||
if !inner.is_empty() {
|
||||
reminders.push(inner.to_string());
|
||||
}
|
||||
let end_abs = after + rel + CLOSE.len();
|
||||
s.replace_range(start..end_abs, "");
|
||||
}
|
||||
None => {
|
||||
// Unterminated tag: the rest of the block is the reminder.
|
||||
let inner = s[after..].trim();
|
||||
if !inner.is_empty() {
|
||||
reminders.push(inner.to_string());
|
||||
}
|
||||
s.truncate(start);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop slash-command wrapper lines (these always occupy their own lines).
|
||||
let prompt = s
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let lt = l.trim_start();
|
||||
!(lt.starts_with("<command-name>")
|
||||
|| lt.starts_with("<command-message>")
|
||||
|| lt.starts_with("<command-args>")
|
||||
|| lt.starts_with("<local-command"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string();
|
||||
(reminders, prompt)
|
||||
}
|
||||
|
||||
/// Record a user-submitted prompt from a request body as a feed entry.
|
||||
/// 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 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).
|
||||
/// 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).
|
||||
pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
|
||||
if body
|
||||
.get("tools")
|
||||
@@ -552,17 +615,34 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
|
||||
if last.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return;
|
||||
}
|
||||
let text = match last.get("content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !is_injected_block(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
// Gather reminders + prompt across every text block of the message.
|
||||
let mut reminders: Vec<String> = Vec::new();
|
||||
let mut prompts: Vec<String> = Vec::new();
|
||||
match last.get("content") {
|
||||
Some(Value::String(s)) => {
|
||||
let (r, p) = extract_user_text(s);
|
||||
reminders.extend(r);
|
||||
if !p.is_empty() {
|
||||
prompts.push(p);
|
||||
}
|
||||
}
|
||||
Some(Value::Array(blocks)) => {
|
||||
for b in blocks {
|
||||
if b.get("type").and_then(Value::as_str) != Some("text") {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = b.get("text").and_then(Value::as_str) {
|
||||
let (r, p) = extract_user_text(t);
|
||||
reminders.extend(r);
|
||||
if !p.is_empty() {
|
||||
prompts.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
}
|
||||
let text = prompts.join("\n");
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
@@ -579,6 +659,14 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
for r in reminders {
|
||||
s.entries.push(Entry {
|
||||
kind: Kind::Reminder,
|
||||
content: r,
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
s.entries.push(Entry {
|
||||
kind: Kind::User,
|
||||
content: text.to_string(),
|
||||
@@ -917,6 +1005,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_system_reminder_is_stripped_not_dropped() {
|
||||
// The first message after a resume often arrives with a
|
||||
// <system-reminder> prepended *inside the same text block* as the real
|
||||
// prompt — the old whole-block filter dropped it entirely.
|
||||
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": [
|
||||
{"type": "text", "text":
|
||||
"<system-reminder>be careful</system-reminder>\n\nresume me please"}
|
||||
]}
|
||||
]}),
|
||||
);
|
||||
let a = app.lock().unwrap();
|
||||
let user: Vec<_> = a.sessions[0]
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.kind == Kind::User)
|
||||
.collect();
|
||||
assert_eq!(user.len(), 1, "the prompt must survive the inline reminder");
|
||||
assert_eq!(user[0].content, "resume me please");
|
||||
// The reminder is kept (shown dimmed), recorded just before the prompt.
|
||||
let rem: Vec<_> = a.sessions[0]
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.kind == Kind::Reminder)
|
||||
.collect();
|
||||
assert_eq!(rem.len(), 1);
|
||||
assert_eq!(rem[0].content, "be careful");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_text_splits_reminders_from_prompt() {
|
||||
let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>");
|
||||
assert_eq!(rem, vec!["x"]);
|
||||
assert_eq!(prompt, "");
|
||||
|
||||
let (rem, prompt) = extract_user_text("hello");
|
||||
assert!(rem.is_empty());
|
||||
assert_eq!(prompt, "hello");
|
||||
|
||||
let (rem, prompt) =
|
||||
extract_user_text("pre <system-reminder>noise</system-reminder> post");
|
||||
assert_eq!(rem, vec!["noise"]);
|
||||
assert_eq!(prompt, "pre post");
|
||||
|
||||
// Unterminated tag → the rest of the block is the reminder.
|
||||
let (rem, prompt) = extract_user_text("keep <system-reminder>dropped");
|
||||
assert_eq!(rem, vec!["dropped"]);
|
||||
assert_eq!(prompt, "keep");
|
||||
|
||||
// strip_injected is the prompt-only projection.
|
||||
assert_eq!(strip_injected("a <system-reminder>b</system-reminder>"), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_rows_scale_with_options() {
|
||||
let two = ask_question_rows(&json!({"questions": [
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! with `-` (the leading `/` becomes a leading `-`).
|
||||
|
||||
use crate::app::{
|
||||
flatten_result_content, is_injected_block, lock_app, Entry, Kind, Session, SharedApp,
|
||||
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
|
||||
ToolResult,
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -144,18 +144,18 @@ fn prompt_text(v: &Value) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
let text = match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::String(s)) => strip_injected(s),
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !is_injected_block(t))
|
||||
.map(strip_injected)
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => return None,
|
||||
};
|
||||
let text = text.trim();
|
||||
(!text.is_empty() && !is_injected_block(text)).then(|| text.to_string())
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// Parse a session file into its turn tree. None when the file has no turns
|
||||
@@ -480,30 +480,11 @@ impl EntryParser {
|
||||
}
|
||||
}
|
||||
Some("user") => match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => {
|
||||
if !s.is_empty() {
|
||||
entries.push(Entry {
|
||||
kind: Kind::Meta,
|
||||
content: format!("❯ {s}"),
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Value::String(s)) => push_user_text(entries, s),
|
||||
Some(Value::Array(blocks)) => {
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(Value::as_str) {
|
||||
Some("text") => {
|
||||
let t = text_of(b, "text");
|
||||
if !t.is_empty() {
|
||||
entries.push(Entry {
|
||||
kind: Kind::Meta,
|
||||
content: format!("❯ {t}"),
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("text") => push_user_text(entries, &text_of(b, "text")),
|
||||
Some("tool_result") => {
|
||||
let Some(idx) = b
|
||||
.get("tool_use_id")
|
||||
@@ -537,6 +518,18 @@ fn text_of(b: &Value, key: &str) -> String {
|
||||
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// Translate a user text block into feed entries: each injected reminder as a
|
||||
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
|
||||
fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
|
||||
let (reminders, prompt) = crate::app::extract_user_text(text);
|
||||
for r in reminders {
|
||||
entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None });
|
||||
}
|
||||
if !prompt.is_empty() {
|
||||
entries.push(Entry { kind: Kind::User, content: prompt, done: true, result: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the current working directory the same way Claude Code does:
|
||||
/// each `/` → `-` (the leading `/` becomes a leading `-`).
|
||||
fn project_dir() -> Result<PathBuf, String> {
|
||||
@@ -812,9 +805,10 @@ mod tests {
|
||||
let s = s.expect("history loaded");
|
||||
assert_eq!(s.key, uuid);
|
||||
assert_eq!(s.model, "claude-x");
|
||||
// ❯ prompt, thinking, tool, text — sidechain line skipped.
|
||||
// user prompt, thinking, tool, text — sidechain line skipped.
|
||||
assert_eq!(s.entries.len(), 4);
|
||||
assert_eq!(s.entries[0].content, "❯ fix the bug");
|
||||
assert!(matches!(s.entries[0].kind, Kind::User));
|
||||
assert_eq!(s.entries[0].content, "fix the bug");
|
||||
assert!(matches!(s.entries[1].kind, Kind::Thinking));
|
||||
let tool = &s.entries[2];
|
||||
assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash"));
|
||||
|
||||
301
src/ui.rs
301
src/ui.rs
@@ -89,7 +89,7 @@ struct FeedCache {
|
||||
}
|
||||
|
||||
struct CachedEntry {
|
||||
fingerprint: (usize, bool, usize, bool),
|
||||
fingerprint: (usize, bool, usize, bool, bool),
|
||||
lines: Vec<Line<'static>>,
|
||||
/// Rows after wrapping to `FeedCache::width` (incl. trailing separator).
|
||||
height: usize,
|
||||
@@ -97,13 +97,17 @@ struct CachedEntry {
|
||||
|
||||
/// Cheap change-detector for a cached entry: content only ever grows (or is
|
||||
/// swapped for the pretty-printed form along with `done`), results attach
|
||||
/// once — length + flags capture every mutation the app performs.
|
||||
fn fingerprint(e: &Entry) -> (usize, bool, usize, bool) {
|
||||
/// once — length + flags capture every mutation the app performs. The trailing
|
||||
/// bool folds in feed focus, but *only* for `Kind::User` entries (their block
|
||||
/// color tracks focus): toggling focus then re-renders just the user blocks,
|
||||
/// not the whole transcript.
|
||||
fn fingerprint(e: &Entry, focused: bool) -> (usize, bool, usize, bool, bool) {
|
||||
let (rlen, rerr) = e
|
||||
.result
|
||||
.as_ref()
|
||||
.map_or((usize::MAX, false), |r| (r.content.len(), r.is_error));
|
||||
(e.content.len(), e.done, rlen, rerr)
|
||||
let focus_bit = matches!(e.kind, Kind::User) && focused;
|
||||
(e.content.len(), e.done, rlen, rerr, focus_bit)
|
||||
}
|
||||
|
||||
/// Detach a `Line` from the text it borrows so it can outlive the app lock.
|
||||
@@ -119,28 +123,106 @@ fn own_line(l: Line<'_>) -> Line<'static> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background for user-prompt entries (indexed so 256-color terminals work).
|
||||
/// Background used to highlight a visual-mode turn range in the session list.
|
||||
const USER_BG: Color = Color::Indexed(17); // deep blue
|
||||
|
||||
/// Focus accent — Claude's orange (indexed so 256-color terminals match it).
|
||||
/// This is the single source of truth for "the feed/pane has keyboard focus":
|
||||
/// borders, the scroll thumb, the user-message markers and the user-message
|
||||
/// blocks all use it when focused.
|
||||
const ACCENT: Color = Color::Indexed(208); // orange
|
||||
/// Background of a focused user-prompt block (the active accent).
|
||||
const USER_BG_ACTIVE: Color = Color::Indexed(208); // orange
|
||||
/// Background of an unfocused user-prompt block (dimmed grey).
|
||||
const USER_BG_DIM: Color = Color::Indexed(238); // dark grey
|
||||
|
||||
/// Style for a user-prompt block: a filled orange box when the feed is focused,
|
||||
/// a dim grey box when it isn't (focus is folded into the per-entry fingerprint
|
||||
/// for `Kind::User` only, so toggling focus re-renders just these blocks). The
|
||||
/// foreground is chosen by `color_on` so the text stays legible on either bg.
|
||||
fn user_block_style(focused: bool) -> Style {
|
||||
let bg = if focused { USER_BG_ACTIVE } else { USER_BG_DIM };
|
||||
let s = Style::new().bg(bg).fg(color_on(bg));
|
||||
if focused { s.bold() } else { s }
|
||||
}
|
||||
|
||||
/// Pick a legible foreground (black or white) for text drawn on `bg`, from the
|
||||
/// background's perceived luminance. A filled block sets its own bg, so this
|
||||
/// keeps the text readable regardless of the user's light/dark terminal theme.
|
||||
fn color_on(bg: Color) -> Color {
|
||||
let (r, g, b) = rgb_of(bg);
|
||||
let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
|
||||
if luma > 140.0 { Color::Black } else { Color::White }
|
||||
}
|
||||
|
||||
/// Approximate 8-bit RGB for a ratatui `Color`, enough to judge brightness:
|
||||
/// RGB passes through; the xterm-256 indexed palette is decoded (16 base + the
|
||||
/// 6×6×6 cube + the 24-step grey ramp); anything else falls back to mid grey.
|
||||
fn rgb_of(c: Color) -> (u8, u8, u8) {
|
||||
match c {
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
Color::Black => (0, 0, 0),
|
||||
Color::White => (255, 255, 255),
|
||||
Color::Indexed(i) => indexed_rgb(i),
|
||||
_ => (128, 128, 128),
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_rgb(i: u8) -> (u8, u8, u8) {
|
||||
const BASE: [(u8, u8, u8); 16] = [
|
||||
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
|
||||
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
|
||||
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
|
||||
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
|
||||
];
|
||||
match i {
|
||||
0..=15 => BASE[i as usize],
|
||||
16..=231 => {
|
||||
const STEP: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
let n = i - 16;
|
||||
(STEP[(n / 36) as usize], STEP[((n / 6) % 6) as usize], STEP[(n % 6) as usize])
|
||||
}
|
||||
_ => {
|
||||
let v = 8 + 10 * (i - 232);
|
||||
(v, v, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one entry to owned lines, including the blank separator row that
|
||||
/// follows every entry in the feed.
|
||||
fn entry_lines(e: &Entry, width: u16) -> Vec<Line<'static>> {
|
||||
/// follows every entry in the feed. `focused` only affects user-prompt blocks.
|
||||
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()),
|
||||
Kind::User => {
|
||||
let style = Style::new().bg(USER_BG).fg(Color::White);
|
||||
let style = user_block_style(focused);
|
||||
let w = (width as usize).max(1);
|
||||
for (i, l) in e.content.lines().enumerate() {
|
||||
let prefix = if i == 0 { "❯ " } else { " " };
|
||||
let mut row = format!("{prefix}{}", sanitize(l));
|
||||
// Pad to a multiple of the feed width so every *wrapped* row
|
||||
// is painted edge-to-edge, not just up to the last character.
|
||||
let rem = row.chars().count() % w;
|
||||
if rem != 0 {
|
||||
row.extend(std::iter::repeat_n(' ', w - rem));
|
||||
// Wrap to the inner feed width ourselves and pad every row to
|
||||
// *exactly* w, so the block is a clean filled rectangle that ends
|
||||
// flush with the borders — no reliance on the Paragraph's own
|
||||
// wrapping (which left a stray, half-empty continuation row), and
|
||||
// no trailing blank: the content is right-trimmed first.
|
||||
let mut first = true;
|
||||
for raw in e.content.trim_end().lines() {
|
||||
let prefixed = format!("{}{}", if first { "❯ " } else { " " }, sanitize(raw));
|
||||
for seg in wrap_words(&prefixed, w) {
|
||||
let mut row = seg;
|
||||
let pad = w.saturating_sub(row.chars().count());
|
||||
row.extend(std::iter::repeat_n(' ', pad));
|
||||
lines.push(Line::from(Span::styled(row, style)));
|
||||
}
|
||||
lines.push(Line::from(Span::styled(row, style)));
|
||||
first = false;
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" ".repeat(w), style)));
|
||||
}
|
||||
}
|
||||
Kind::Reminder => {
|
||||
// Injected context Claude Code received — shown dim, like thinking.
|
||||
lines.push(Line::from("⌁ system reminder").dark_gray().italic());
|
||||
for l in e.content.lines() {
|
||||
lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic());
|
||||
}
|
||||
}
|
||||
Kind::Thinking => {
|
||||
@@ -623,14 +705,14 @@ fn event_loop(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Model picker (opened with `n`): j/k move, Enter spawns a fresh
|
||||
// session with the chosen model, esc/n/q cancels.
|
||||
// Model picker (opened with `a`): j/k move, Enter spawns a fresh
|
||||
// session with the chosen model, esc/a/q cancels.
|
||||
if let Some(msel) = a.model_popup {
|
||||
let n = a.model_choices.len().max(1);
|
||||
match k.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n),
|
||||
KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n),
|
||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => a.model_popup = None,
|
||||
KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None,
|
||||
KeyCode::Enter => {
|
||||
a.model_popup = None;
|
||||
let model = a.model_choices.get(msel).map(|c| c.1.clone());
|
||||
@@ -652,9 +734,19 @@ fn event_loop(
|
||||
None => return Ok(()),
|
||||
},
|
||||
KeyCode::Char('f') => a.filter_popup = Some(0),
|
||||
// n → pick a model, then spawn a brand-new session (no need to
|
||||
// a → pick a model, then spawn a brand-new session (no need to
|
||||
// resume + /clear just to get a fresh chat).
|
||||
KeyCode::Char('n') => a.model_popup = Some(0),
|
||||
KeyCode::Char('a') => a.model_popup = Some(0),
|
||||
// n / N → jump the feed scroll to the next / previous user
|
||||
// prompt (honoured on the next draw, where entry heights live).
|
||||
KeyCode::Char('n') => {
|
||||
a.follow = false;
|
||||
a.prompt_jump = Some(true);
|
||||
}
|
||||
KeyCode::Char('N') => {
|
||||
a.follow = false;
|
||||
a.prompt_jump = Some(false);
|
||||
}
|
||||
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
|
||||
// c → attach the most recent past session (like `claude -c`):
|
||||
// select it (the scanner keeps disk_sessions newest-first),
|
||||
@@ -689,8 +781,9 @@ fn event_loop(
|
||||
}
|
||||
Err(e) => a.status = e,
|
||||
},
|
||||
// Tab / BackTab cycle sessions. `n` is the new-session picker;
|
||||
// `p` is no longer a back-tab mirror.
|
||||
// Tab / BackTab cycle sessions. `a` is the new-session picker;
|
||||
// `n`/`N` jump between user prompts; `p` is no longer a
|
||||
// back-tab mirror.
|
||||
KeyCode::Tab if nsess > 0 => {
|
||||
a.selected = (a.selected + 1) % nsess;
|
||||
a.follow = true;
|
||||
@@ -779,7 +872,7 @@ fn draw(
|
||||
// dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on.
|
||||
let border_style = |focused: bool| {
|
||||
if focused {
|
||||
Style::new().cyan().bold()
|
||||
Style::new().fg(ACCENT).bold()
|
||||
} else {
|
||||
Style::new().dark_gray()
|
||||
}
|
||||
@@ -818,25 +911,40 @@ fn draw(
|
||||
Some((av.min(p), av.max(p)))
|
||||
});
|
||||
let white = Style::new().fg(Color::White);
|
||||
// The session whose `claude` child we spawned and is still alive: the
|
||||
// one running instance this app owns (cleared the moment the pane
|
||||
// exits). It gets a bright accent marker + accent title so it reads as
|
||||
// "running here" at a glance; external live sessions only show a green
|
||||
// dot while they're actively streaming (their instance may have ended —
|
||||
// liveness is unknowable), and disk stubs stay dimmed.
|
||||
let embed_key = a.embed_session.clone();
|
||||
for m in 0..live_n + stubs.len() {
|
||||
// Every session is a multi-line item: the full title (white,
|
||||
// wrapped to the panel width — continuation rows aligned under
|
||||
// it) followed by a dimmed meta row (status dot + id + model for
|
||||
// live sessions, just the id for disk stubs).
|
||||
let (uuid, lead, title, meta) = if m < live_n {
|
||||
let (uuid, lead, title, meta, title_style) = if m < live_n {
|
||||
let s = &a.sessions[m];
|
||||
let lead = if s.active > 0 { "● ".green() } else { "○ ".dark_gray() };
|
||||
let is_embed = embed_key.as_deref() == Some(s.key.as_str());
|
||||
let lead = if is_embed {
|
||||
"▶ ".fg(ACCENT).bold()
|
||||
} else if s.active > 0 {
|
||||
"● ".green()
|
||||
} else {
|
||||
"○ ".dark_gray()
|
||||
};
|
||||
let id: String = s.key.chars().take(8).collect();
|
||||
(
|
||||
s.key.clone(),
|
||||
lead,
|
||||
live_title(s),
|
||||
format!("{id} · {}", short_model(&s.model)),
|
||||
)
|
||||
let meta = if is_embed {
|
||||
format!("{id} · {} · running", short_model(&s.model))
|
||||
} else {
|
||||
format!("{id} · {}", short_model(&s.model))
|
||||
};
|
||||
let title_style = if is_embed { Style::new().fg(ACCENT).bold() } else { white };
|
||||
(s.key.clone(), lead, live_title(s), meta, title_style)
|
||||
} else {
|
||||
let d = &a.disk_sessions[stubs[m - live_n]];
|
||||
let id: String = d.uuid.chars().take(8).collect();
|
||||
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id)
|
||||
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id, white)
|
||||
};
|
||||
let mut rows: Vec<Line> = Vec::new();
|
||||
for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2))
|
||||
@@ -844,9 +952,9 @@ fn draw(
|
||||
.enumerate()
|
||||
{
|
||||
if i == 0 {
|
||||
rows.push(Line::from(vec![lead.clone(), Span::styled(w, white)]));
|
||||
rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)]));
|
||||
} else {
|
||||
rows.push(Line::from(Span::styled(format!(" {w}"), white)));
|
||||
rows.push(Line::from(Span::styled(format!(" {w}"), title_style)));
|
||||
}
|
||||
}
|
||||
rows.push(Line::from(format!(" {meta}")).dark_gray());
|
||||
@@ -871,7 +979,7 @@ fn draw(
|
||||
let avail = inner_w.saturating_sub(prefix.chars().count());
|
||||
let txt = format!("{prefix}{}", truncate_str(&turn.label, avail));
|
||||
let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) {
|
||||
Line::from(txt).style(Style::new().bg(USER_BG).fg(Color::White))
|
||||
Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG)))
|
||||
} else {
|
||||
Line::from(txt).dark_gray()
|
||||
};
|
||||
@@ -948,6 +1056,9 @@ fn draw(
|
||||
_ => None,
|
||||
};
|
||||
a.turn_dirty = false;
|
||||
// `n`/`N`: jump to the next/previous user prompt. Taken once, here, before
|
||||
// the feed borrow so we can clear it (offset computed below from the cache).
|
||||
let prompt_jump = a.prompt_jump.take();
|
||||
let (feed_session, feed_leaf): (Option<&Session>, Option<usize>) = match &hist_view {
|
||||
Some((u, _)) => {
|
||||
let h = a.history.get(u);
|
||||
@@ -975,9 +1086,9 @@ fn draw(
|
||||
cache.entries.clear();
|
||||
}
|
||||
for (i, e) in s.entries.iter().enumerate() {
|
||||
let fp = fingerprint(e);
|
||||
let fp = fingerprint(e, feed_focused);
|
||||
if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) {
|
||||
let lines = entry_lines(e, feed_width);
|
||||
let lines = entry_lines(e, feed_width, feed_focused);
|
||||
let height = wrapped_height(&lines, feed_width);
|
||||
let ce = CachedEntry { fingerprint: fp, lines, height };
|
||||
if i < cache.entries.len() {
|
||||
@@ -1010,6 +1121,26 @@ fn draw(
|
||||
.min(max_scroll);
|
||||
new_follow = false;
|
||||
}
|
||||
if let Some(down) = prompt_jump {
|
||||
// Wrapped-row offset of each visible user prompt's first row.
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for &i in &visible {
|
||||
if matches!(s.entries[i].kind, Kind::User) {
|
||||
offsets.push(acc);
|
||||
}
|
||||
acc += cache.entries[i].height;
|
||||
}
|
||||
let pick = if down {
|
||||
offsets.iter().copied().find(|&o| o > scroll0)
|
||||
} else {
|
||||
offsets.iter().rev().copied().find(|&o| o < scroll0)
|
||||
};
|
||||
if let Some(o) = pick {
|
||||
new_scroll = o.min(max_scroll);
|
||||
new_follow = false;
|
||||
}
|
||||
}
|
||||
// Reaching the bottom re-engages follow automatically.
|
||||
if new_scroll >= max_scroll {
|
||||
new_follow = true;
|
||||
@@ -1066,31 +1197,62 @@ fn draw(
|
||||
right,
|
||||
);
|
||||
|
||||
// Scroll-position indicator: overdraw a thick segment of the feed's
|
||||
// right border (heavy vertical `┃`) marking the visible window's
|
||||
// position within the whole transcript. Shown only when scrollable.
|
||||
if total > height && height > 0 && right.width >= 2 {
|
||||
// Thumb height/position proportional to the viewport vs. total.
|
||||
let thumb = ((height * height) / total).max(1).min(height);
|
||||
let max_scroll = total - height;
|
||||
let thumb_top = if max_scroll == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_scroll * (height - thumb)) / max_scroll
|
||||
};
|
||||
// Right-border overlay: first `*` markers showing where the user's own
|
||||
// messages sit in the whole conversation (a minimap of prompts), then
|
||||
// the scroll thumb painted on top of them where they coincide.
|
||||
if height > 0 && right.width >= 2 {
|
||||
let col = right.x + right.width - 1;
|
||||
// Solid block fully fills the cell (a heavy `┃` line still reads as
|
||||
// a thin, dim stroke). Brighter than the border so the thumb stands
|
||||
// out as an indicator: cyan when focused, gray otherwise.
|
||||
let style = if feed_focused {
|
||||
Style::new().cyan()
|
||||
} else {
|
||||
Style::new().fg(Color::Gray)
|
||||
// Map a wrapped-row offset within the transcript to a border row.
|
||||
// When everything fits, offsets are 1:1 with screen rows; once
|
||||
// scrollable, compress the whole transcript onto the track.
|
||||
let track_row = |offset: usize| -> u16 {
|
||||
let r = if total <= height {
|
||||
offset
|
||||
} else {
|
||||
offset * height / total
|
||||
};
|
||||
r.min(height - 1) as u16
|
||||
};
|
||||
let buf = f.buffer_mut();
|
||||
for k in 0..thumb {
|
||||
let y = right.y + 1 + (thumb_top + k) as u16;
|
||||
buf[(col, y)].set_symbol("█").set_style(style);
|
||||
// Markers track focus like the prompt blocks: orange when focused,
|
||||
// dim grey when not.
|
||||
let marker_style = if feed_focused {
|
||||
Style::new().fg(ACCENT).bold()
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
let mut acc = 0usize;
|
||||
{
|
||||
let buf = f.buffer_mut();
|
||||
for &i in &visible {
|
||||
if matches!(s.entries[i].kind, Kind::User) {
|
||||
let y = right.y + 1 + track_row(acc);
|
||||
buf[(col, y)].set_symbol("*").set_style(marker_style);
|
||||
}
|
||||
acc += cache.entries[i].height;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll thumb: a solid block marking the visible window's position
|
||||
// within the whole transcript, drawn last so it sits *on top* of a
|
||||
// marker at the same row. Shown only when scrollable.
|
||||
if total > height {
|
||||
let thumb = ((height * height) / total).max(1).min(height);
|
||||
let max_scroll = total - height;
|
||||
let thumb_top = if max_scroll == 0 {
|
||||
0
|
||||
} else {
|
||||
(new_scroll * (height - thumb)) / max_scroll
|
||||
};
|
||||
let style = if feed_focused {
|
||||
Style::new().fg(ACCENT)
|
||||
} else {
|
||||
Style::new().fg(Color::Gray)
|
||||
};
|
||||
let buf = f.buffer_mut();
|
||||
for k in 0..thumb {
|
||||
let y = right.y + 1 + (thumb_top + k) as u16;
|
||||
buf[(col, y)].set_symbol("█").set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1627,7 +1789,7 @@ fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, wid
|
||||
row.extend(std::iter::repeat_n(' ', pad));
|
||||
out.push(Line::from(Span::styled(
|
||||
row,
|
||||
Style::new().bg(bg).fg(Color::White),
|
||||
Style::new().bg(bg).fg(color_on(bg)),
|
||||
)));
|
||||
}
|
||||
None => out.push(Line::from(vec![
|
||||
@@ -1654,7 +1816,18 @@ fn sanitize(l: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{base64, truncate_str, wrap_words};
|
||||
use super::{base64, color_on, truncate_str, wrap_words};
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn color_on_contrasts_with_background() {
|
||||
// Bright orange / white → black text; dark grey / blue → white text.
|
||||
assert_eq!(color_on(Color::Indexed(208)), Color::Black);
|
||||
assert_eq!(color_on(Color::White), Color::Black);
|
||||
assert_eq!(color_on(Color::Indexed(238)), Color::White);
|
||||
assert_eq!(color_on(Color::Indexed(17)), Color::White);
|
||||
assert_eq!(color_on(Color::Rgb(0, 0, 0)), Color::White);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_words_greedy_and_hard_splits() {
|
||||
|
||||
Reference in New Issue
Block a user