From 10279a49a416c75b249dcb33e45477dcd4b7b101 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Tue, 16 Jun 2026 11:04:50 +0200 Subject: [PATCH] ui updates --- CLAUDE.md | 8 +- src/ui.rs | 290 ++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 245 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7b238c..21e5597 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,13 @@ src/app.rs Arc> shared state; Tap = one in-flight tapped request, src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed (FeedCache: per-entry rendered lines + wrapped heights, only changed entries re-render; the viewport window of lines is - handed to ratatui so scroll state is usize end-to-end) + handed to ratatui so scroll state is usize end-to-end). + Sessions panel is a uniform 50% of the main area: each session + is a multi-line item — full white title (live = first user + prompt via `live_title`, stub = disk label, word-wrapped by + `wrap_words`) over a dimmed id·model meta row; expanded turn + rows are indented past the title and `truncate_str`'d to one + line each. src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing, width-fitted wrapped columns) and strips heading `#` markers — the pinned tui-markdown 0.3.5 does neither diff --git a/src/ui.rs b/src/ui.rs index 9a8e5a5..c34e160 100644 --- a/src/ui.rs +++ b/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 = 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 = 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 = 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 { + let width = width.max(1); + let mut out: Vec = Vec::new(); + let mut cur = String::new(); + let mut cur_len = 0usize; + let push_word = |out: &mut Vec, 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() {