ui updates
This commit is contained in:
290
src/ui.rs
290
src/ui.rs
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user