ui updates

This commit is contained in:
Jonas H
2026-06-16 11:04:50 +02:00
parent a72eafc42a
commit 10279a49a4
2 changed files with 245 additions and 53 deletions

290
src/ui.rs
View File

@@ -736,22 +736,28 @@ fn draw(
} else {
0
};
// Keyboard focus lives in exactly one place: the claude pane (when it has
// focus) or the feed/sessions area otherwise. One rule for every panel:
// focused → bold accent border, unfocused → dimmed. So the feed (and the
// sessions panel, if shown) and the pane all read the same way, and the
// dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on.
let border_style = |focused: bool| {
if focused {
Style::new().cyan().bold()
} else {
Style::new().dark_gray()
}
};
let feed_focused = !eui.focused();
let [main, embed_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(embed_h),
Constraint::Length(1),
])
.areas(f.area());
// The panel needs room for turn labels while a tree is expanded.
let left_width = if a.show_sessions {
if a.expanded.is_some() {
44.min(main.width / 2)
} else {
26
}
} else {
0
};
// Uniform: the sessions panel is always half the width, so titles have
// room to read the same whether or not a turn tree is expanded.
let left_width = if a.show_sessions { main.width / 2 } else { 0 };
let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)])
.areas(main);
@@ -766,34 +772,49 @@ fn draw(
// expanded session's turn rows render directly under its row, abandoned
// branches indented one level under their fork point (⑂).
if a.show_sessions {
// Inner content width (panel minus its border): titles wrap to this,
// turn labels truncate to it.
let inner_w = left.width.saturating_sub(2).max(1) as usize;
let mut items: Vec<ListItem> = Vec::new();
let mut flat_sel = 0usize;
let vis_range = a.expanded.as_ref().and_then(|e| {
let (av, p) = (e.visual?, e.sel?);
Some((av.min(p), av.max(p)))
});
let white = Style::new().fg(Color::White);
for m in 0..live_n + stubs.len() {
let (uuid, item) = if m < live_n {
// 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 s = &a.sessions[m];
let dot = if s.active > 0 { "".green() } else { "".dark_gray() };
let lead = if s.active > 0 { " ".green() } else { " ".dark_gray() };
let id: String = s.key.chars().take(8).collect();
(
s.key.clone(),
ListItem::new(Line::from(vec![
dot,
" ".into(),
id.into(),
" ".into(),
short_model(&s.model).cyan(),
])),
lead,
live_title(s),
format!("{id} · {}", short_model(&s.model)),
)
} else {
let d = &a.disk_sessions[stubs[m - live_n]];
(
d.uuid.clone(),
ListItem::new(Line::from(format!("· {}", d.label)).dark_gray()),
)
let id: String = d.uuid.chars().take(8).collect();
(d.uuid.clone(), "· ".dark_gray(), d.label.clone(), id)
};
let mut rows: Vec<Line> = Vec::new();
for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2))
.into_iter()
.enumerate()
{
if i == 0 {
rows.push(Line::from(vec![lead.clone(), Span::styled(w, white)]));
} else {
rows.push(Line::from(Span::styled(format!(" {w}"), white)));
}
}
rows.push(Line::from(format!(" {meta}")).dark_gray());
let on_sel_row = m == sel;
let turn_hl = a
.expanded
@@ -803,16 +824,16 @@ fn draw(
if on_sel_row && turn_hl.is_none() {
flat_sel = items.len();
}
items.push(item);
items.push(ListItem::new(rows));
if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) {
for (p, &t) in e.tree.display.iter().enumerate() {
let turn = &e.tree.turns[t];
let bullet = if turn.depth > 0 { "" } else { "" };
let txt = format!(
" {}{bullet} {}",
" ".repeat(turn.depth.min(6)),
turn.label
);
// Indent turns past the title gutter, then by tree depth;
// labels truncate (never wrap) so one row = one turn.
let prefix = format!(" {}{bullet} ", " ".repeat(turn.depth.min(6)));
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))
} else {
@@ -831,7 +852,9 @@ fn draw(
}
f.render_stateful_widget(
List::new(items)
.block(Block::bordered().title(" sessions "))
.block(
Block::bordered().title(" sessions ").border_style(border_style(feed_focused)),
)
.highlight_style(ratatui::style::Style::new().reversed()),
left,
&mut ls,
@@ -1000,12 +1023,40 @@ fn draw(
};
let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
f.render_widget(
p.block(Block::bordered().title(title))
// residual < first visible entry's height; an entry would
// need >65k wrapped rows of its own to hit the clamp.
.scroll((residual.min(u16::MAX as usize) as u16, 0)),
p.block(Block::bordered().title(title).border_style(border_style(feed_focused)))
// residual < first visible entry's height; an entry would
// need >65k wrapped rows of its own to hit the clamp.
.scroll((residual.min(u16::MAX as usize) as u16, 0)),
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
};
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)
};
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 {
f.render_widget(
Paragraph::new(format!(
@@ -1013,7 +1064,7 @@ fn draw(
eui.port
))
.dark_gray()
.block(Block::bordered()),
.block(Block::bordered().border_style(border_style(feed_focused))),
right,
);
}
@@ -1031,12 +1082,9 @@ fn draw(
} else {
format!(" claude · {id} ")
};
// Bold cyan border = the pane has keyboard focus.
let block = if embed_focused {
Block::bordered().title(title).border_style(Style::new().cyan().bold())
} else {
Block::bordered().title(title).border_style(Style::new().dark_gray())
};
// Bold accent border = keyboard focus; dimmed = unfocused (same rule
// as the feed/sessions panels above).
let block = Block::bordered().title(title).border_style(border_style(embed_focused));
let inner = block.inner(embed_area);
f.render_widget(block, embed_area);
if exited {
@@ -1146,13 +1194,51 @@ fn draw(
std::mem::swap(&mut from, &mut to);
}
let buf = f.buffer_mut();
let area = buf.area;
let mut copied = String::new();
for y in from.1..=to.1.min(area.bottom().saturating_sub(1)) {
let x_from = if y == from.1 { from.0 } else { area.left() };
let x_to = if y == to.1 { to.0 } else { area.right().saturating_sub(1) };
// Clip the selection to one panel's *inner* content box so a multi-row
// sweep grabs only that panel's text — never an adjacent panel or the
// box-drawing borders between them (a linear selection otherwise spans
// the full screen width on its middle rows, which is what produced the
// border/padding artefacts). The panel is chosen by where the drag
// began, hit-tested against each panel's *outer* rect so starting on a
// border or in the margin still resolves to the panel; the feed is the
// default, since that is what prose gets copied from.
let contains = |r: Rect, p: (u16, u16)| {
p.0 >= r.left() && p.0 < r.right() && p.1 >= r.top() && p.1 < r.bottom()
};
let outer = if left.width > 0 && contains(left, s.start) {
left
} else if embed_h > 0 && contains(embed_area, s.start) {
embed_area
} else {
right
};
// Inner content box = the bordered rect minus its 1-cell border.
let region = Rect {
x: outer.x.saturating_add(1),
y: outer.y.saturating_add(1),
width: outer.width.saturating_sub(2),
height: outer.height.saturating_sub(2),
};
let (clip_l, clip_r, clip_t, clip_b) = (
region.left(),
region.right().saturating_sub(1),
region.top(),
region.bottom().saturating_sub(1),
);
let y_start = from.1.max(clip_t);
let y_end = to.1.min(clip_b);
let mut lines: Vec<String> = Vec::new();
for y in y_start..=y_end {
let x_from = (if y == from.1 { from.0 } else { clip_l }).max(clip_l);
let x_to = (if y == to.1 { to.0 } else { clip_r }).min(clip_r);
if x_from > x_to {
if s.copy_pending {
lines.push(String::new());
}
continue;
}
let mut line = String::new();
for x in x_from..=x_to.min(area.right().saturating_sub(1)) {
for x in x_from..=x_to {
if let Some(c) = buf.cell_mut(Position::new(x, y)) {
if s.copy_pending {
line.push_str(c.symbol());
@@ -1162,13 +1248,24 @@ fn draw(
}
}
if s.copy_pending {
copied.push_str(line.trim_end());
copied.push('\n');
// Drop trailing padding spaces (the buffer is space-filled to
// the panel width) so only real text — and its line breaks —
// ends up on the clipboard.
lines.push(line.trim_end().to_string());
}
}
if s.copy_pending {
let copied = copied.trim_end_matches('\n');
osc52_copy(copied);
// Trim blank rows off both ends: the empty padding lines above and
// below the text would otherwise paste as stray newlines (each one
// a submit in a prompt). Interior blanks (paragraph breaks) stay.
while lines.first().is_some_and(|l| l.is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
let copied = lines.join("\n");
osc52_copy(&copied);
app.lock().unwrap().status =
format!("copied {} chars to clipboard", copied.chars().count());
// selection stays None: the highlight disappears with the copy.
@@ -1206,6 +1303,80 @@ fn short_model(m: &str) -> String {
m.strip_prefix("claude-").unwrap_or(m).to_string()
}
/// A human title for a live session: the first line of its first user prompt
/// (what the session is *about*), falling back to the model / a placeholder
/// before any prompt has streamed in.
fn live_title(s: &Session) -> String {
if let Some(e) = s.entries.iter().find(|e| matches!(e.kind, Kind::User)) {
let first = e.content.lines().map(str::trim).find(|l| !l.is_empty());
if let Some(t) = first.filter(|t| !t.is_empty()) {
return t.to_string();
}
}
if s.entries.is_empty() {
"(new session)".into()
} else {
short_model(&s.model)
}
}
/// Greedy word-wrap to `width` columns (char-counted). Words longer than the
/// width are hard-split. Always returns at least one (possibly empty) row.
fn wrap_words(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_len = 0usize;
let push_word = |out: &mut Vec<String>, cur: &mut String, cur_len: &mut usize, word: &str| {
let wlen = word.chars().count();
if *cur_len == 0 {
// start of a row
} else if *cur_len + 1 + wlen <= width {
cur.push(' ');
*cur_len += 1;
} else {
out.push(std::mem::take(cur));
*cur_len = 0;
}
if wlen <= width {
cur.push_str(word);
*cur_len += wlen;
} else {
// hard-split an over-long word
for c in word.chars() {
if *cur_len == width {
out.push(std::mem::take(cur));
*cur_len = 0;
}
cur.push(c);
*cur_len += 1;
}
}
};
for word in text.split_whitespace() {
push_word(&mut out, &mut cur, &mut cur_len, word);
}
out.push(cur);
if out.is_empty() {
out.push(String::new());
}
out
}
/// Single-line truncation with an ellipsis when the text doesn't fit.
fn truncate_str(s: &str, width: usize) -> String {
let s = sanitize(s);
if s.chars().count() <= width {
return s;
}
if width == 0 {
return String::new();
}
let mut out: String = s.chars().take(width - 1).collect();
out.push('…');
out
}
/// Per-tool human-readable rendering, plus the tool_result (if it has come
/// back in a subsequent request) attached underneath.
fn render_tool<'a>(
@@ -1443,7 +1614,22 @@ fn sanitize(l: &str) -> String {
#[cfg(test)]
mod tests {
use super::base64;
use super::{base64, truncate_str, wrap_words};
#[test]
fn wrap_words_greedy_and_hard_splits() {
assert_eq!(wrap_words("", 10), vec![""]);
assert_eq!(wrap_words("a b c", 3), vec!["a b", "c"]);
// an over-long word is hard-split at the width boundary
assert_eq!(wrap_words("abcdef", 2), vec!["ab", "cd", "ef"]);
}
#[test]
fn truncate_str_adds_ellipsis() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello", 3), "he…");
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn base64_matches_rfc4648_vectors() {