From 0b79e0fc42941e50757aff61cac03da84267a63f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:29:07 +0000 Subject: [PATCH 1/6] Sonny: Home with nothing typed goes to the first turn, End to the newest On the full screen, Home with an empty composer goes up to the conversation's first turn. On the way the pages over it are only read, 400 nodes at a time, for what their calls returned: they stand as blank rows as tall as the pages drawn so far suggest, and are drawn once the view nears them. The first page is drawn at once. A key or the wheel stops the way up. End goes back to the newest rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/README.md | 4 +- sonny/src/main.rs | 230 +++++++++++++++++++++++++++++++++++--------- sonny/src/screen.rs | 88 ++++++++++------- 3 files changed, 241 insertions(+), 81 deletions(-) diff --git a/sonny/README.md b/sonny/README.md index c4aca6394..eb839c404 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -600,7 +600,9 @@ Shift+Cmd+click in Ghostty) opens a file outside Sonny from the conversation. Keys go to the pane after a click in it; Tab takes them back to the composer, and Tab with nothing typed puts them in the pane again. A window too narrow for -both shows the one the keys are in. Ctrl+F finds text in +both shows the one the keys are in. With nothing typed, Home goes up to +the conversation's first turn, drawing the turns over it on the way, and +End comes back to the newest. Ctrl+F finds text in the conversation, from the newest place back: โ†‘ or Enter steps older, โ†“ newer, and Esc stops where you are. `/fullscreen` switches to inline, where Sonny writes into the terminal's own scrollback, and back; diff --git a/sonny/src/main.rs b/sonny/src/main.rs index ceb0d1089..c45f9868a 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -117,6 +117,11 @@ const HISTORY_ITEMS: u64 = 200; /// How much older history is drawn at a time as the view nears its top: /// a page drawn while the wheel turns, so small enough not to stall it. const OLDER_ITEMS: u64 = 50; +/// How many nodes the way up to the first turn reads at a time, cut +/// into pages of [`OLDER_ITEMS`]. +const WALKED_ITEMS: u64 = 400; +/// About how many pages the way up to the first turn reads between frames. +const WALKED_PAGES: usize = 32; /// The widest the conversation is written, however wide the window: a /// reply reads as a column, not a band across the screen. Prose reads /// best at 45 to 90 characters a line, but replies carry code, paths and @@ -1236,6 +1241,36 @@ impl Paged { } } +/// The reaction a call to react makes in this window, not another +/// agent's. +fn own_reaction(item: &Value) -> Option<(String, bool)> { + (item["arguments"].as_str()) + .and_then(|a| serde_json::from_str::(a).ok()) + .filter(|arguments| reactions::target(arguments).is_none()) + .as_ref() + .and_then(reactions::call) +} + +/// What `items` give back undrawn, as drawing them as older items would: +/// what the calls before them returned. +fn unpaired(items: impl IntoIterator) -> Vec<(i64, Value)> { + let mut calls = std::collections::HashSet::new(); + let mut unpaired = Vec::new(); + for (node, item) in items { + let id = item["call_id"].as_str().unwrap_or("").to_owned(); + match item["type"].as_str() { + Some("function_call") + if item["name"] != reactions::TOOL || own_reaction(&item).is_some() => + { + calls.insert(id); + } + Some("function_call_output") if !calls.remove(&id) => unpaired.push((node, item)), + _ => {} + } + } + unpaired +} + /// What the bot is saying: its rows stay in the viewport until what comes /// next tells whether they were a step on the way, before a call, or its /// reply, at the turn's end. Rows past what the viewport holds are written @@ -1621,6 +1656,12 @@ struct App { /// A read of the bot's items came back short: what was drawn from it /// is not all there was. short_read: std::cell::Cell, + /// Home, with nothing typed, goes on up to the first turn as the pages + /// over it are drawn, until a key or the wheel says otherwise. + to_first: bool, + /// How many rows the pages drawn took, and for how many items: how + /// tall a page not drawn yet is guessed to be. + page_rows: (usize, usize), /// The window's width, the pane's included. full_width: u16, /// The pane beside the conversation on the full screen, with what the @@ -2281,6 +2322,8 @@ impl App { pages: HashMap::new(), next_page: 0, short_read: std::cell::Cell::new(false), + to_first: false, + page_rows: (0, 0), full_width: 0, pane: None, pane_keys: false, @@ -2501,8 +2544,18 @@ impl App { if full() && self.older.is_some() && self.screen.near_top() { self.older().await; } - // A page let go drawn again before the view reaches it. + if self.to_first { + self.screen.scroll(isize::MAX); + self.to_first = full() && self.older.is_some() && self.screen.near_top(); + // The next page asked for once this one is drawn, not a tick on. + if self.to_first { + self.woken.notify_one(); + } + } + // A page let go drawn again before the view reaches it, or + // once the way up to the first turn is done. if full() + && !self.to_first && let Some(page) = self.screen.wanted() { self.draw_again(page).await; @@ -2839,7 +2892,7 @@ impl App { let unpaired = std::mem::take(&mut self.unpaired); self.history_since(newest, true).await; self.unpaired = unpaired; - self.screen.blank(pages); + self.screen.prepend_blank(pages); return Ok(()); } let welcome = self.welcome(); @@ -2854,45 +2907,91 @@ impl App { /// The page of items before the oldest drawn, drawn on its own and /// put above the rest, the view left where it is: the conversation - /// drawn so far is not drawn again, and a turn running goes on. + /// drawn so far is not drawn again, and a turn running goes on. On + /// the way up to the first turn, the pages over it are blank rows as + /// tall as they may be, a few at a time, drawn once the view nears + /// them. async fn older(&mut self) { - let Some(from) = self.older else { - return; - }; - let asked = json!({"bot": self.bot, "from": from, "limit": OLDER_ITEMS}); - let page = match self.client.request("history_nodes", asked).await { - Ok(page) => page, - Err(error) => { - self.notice = Some(error.to_string()); - return; + // Newest first, those on the way up, by their ids and rows. + let mut blanks: Vec<(u64, usize)> = Vec::new(); + while let Some(from) = self.older + && blanks.len() < WALKED_PAGES + { + // On the way up, as many nodes as a read gives, cut into pages. + let limit = match self.to_first { + true => WALKED_ITEMS, + false => OLDER_ITEMS, + }; + let asked = json!({"bot": self.bot, "from": from, "limit": limit}); + let page = match self.client.request("history_nodes", asked).await { + Ok(page) => page, + Err(error) => { + self.notice = Some(error.to_string()); + break; + } + }; + let mut nodes: Vec = Vec::new(); + let mut turns: HashMap = HashMap::new(); + for n in page["nodes"].as_array().into_iter().flatten() { + let Some(node) = n["node"].as_i64() else { + continue; + }; + nodes.push(node); + if let Some(turn) = n["turn"].as_i64() { + turns.insert(node, turn); + } } - }; - let mut nodes: Vec = Vec::new(); - let mut turns: HashMap = HashMap::new(); - for n in page["nodes"].as_array().into_iter().flatten() { - let Some(node) = n["node"].as_i64() else { - continue; + nodes.reverse(); + self.older = page["next_from"].as_i64(); + if nodes.is_empty() { + self.older = None; + break; + } + self.short_read.set(false); + let mut items = match self.to_first { + true => self.items_at(&nodes).await, + false => Vec::new(), }; - nodes.push(node); - if let Some(turn) = n["turn"].as_i64() { - turns.insert(node, turn); + // A read cut short: the rest of the way drawn a page at a time. + if self.short_read.get() { + self.older = Some(from); + self.to_first = false; + break; + } + while !nodes.is_empty() { + let cut = nodes.len().saturating_sub(OLDER_ITEMS as usize); + let ours = nodes.split_off(cut); + self.first_node = Some(ours[0]); + let turns = (ours.iter()) + .filter_map(|node| Some((*node, *turns.get(node)?))) + .collect(); + let after = std::mem::take(&mut self.unpaired); + let first = self.older.is_none() && nodes.is_empty(); + let paged = Paged::new(ours, turns, &after, first); + let id = self.next_page; + self.next_page += 1; + let cut = items.partition_point(|(node, _)| *node < paged.nodes[0]); + let read = items.split_off(cut); + if !self.to_first || paged.first { + blanks.reverse(); + self.screen.prepend_blank(blanks); + let (lines, unpaired) = self.draw_page(&paged, after).await; + self.unpaired = unpaired; + self.page_rows.0 += lines.len(); + self.page_rows.1 += paged.nodes.len(); + self.pages.insert(id, paged); + self.screen.prepend(id, lines); + return; + } + // Only read: what its calls returned is all drawing it gives. + self.unpaired = unpaired(read.into_iter().chain(after)); + let (rows, items) = self.page_rows; + blanks.push((id, paged.nodes.len() * rows.max(1) / items.max(1))); + self.pages.insert(id, paged); } } - nodes.reverse(); - self.older = page["next_from"].as_i64(); - let Some(&oldest) = nodes.first() else { - self.older = None; - return; - }; - self.first_node = Some(oldest); - let after = std::mem::take(&mut self.unpaired); - let paged = Paged::new(nodes, turns, &after, self.older.is_none()); - let (lines, unpaired) = self.draw_page(&paged, after).await; - self.unpaired = unpaired; - let id = self.next_page; - self.next_page += 1; - self.pages.insert(id, paged); - self.screen.prepend(id, lines); + blanks.reverse(); + self.screen.prepend_blank(blanks); } /// The page `id`, let go as the view moved away, drawn again as it @@ -3336,13 +3435,7 @@ impl App { // Counted once it is known to have been taken. // One naming another agent is drawn in that agent's window. (Some("function_call"), _) if item["name"] == reactions::TOOL => { - if let Some(reaction) = item["arguments"] - .as_str() - .and_then(|a| serde_json::from_str::(a).ok()) - .filter(|arguments| reactions::target(arguments).is_none()) - .as_ref() - .and_then(reactions::call) - { + if let Some(reaction) = own_reaction(&item) { let id = item["call_id"].as_str().unwrap_or("").to_owned(); self.react_calls.insert(id, reaction); } @@ -4837,6 +4930,20 @@ impl App { /// What the full screen takes before the rest: the mouse, a copy of /// the selection, and the keys that scroll. Whether it took `event`. fn full_screen(&mut self, event: &Event) -> bool { + use ratatui::crossterm::event::MouseEventKind; + // A key, or the wheel, ends the way up to the first turn. + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => self.to_first = false, + Event::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + self.to_first = false + } + _ => {} + } let key = match event { // A paste while finding is more to find. // A paste in the pane's program is its own. @@ -5112,6 +5219,20 @@ impl App { KeyCode::Esc if self.screen.unselect() || self.input.unselect() => {} KeyCode::PageUp => self.page_up(), KeyCode::PageDown => self.screen.page(false), + // With nothing typed, Home goes to the first turn, the pages + // over it drawn on the way, and End back to the newest. + KeyCode::Home | KeyCode::End + if self.input.is_empty() + && self.picker.is_none() + && self.choosing.is_none() + && self.reacting.is_none() => + { + self.to_first = key.code == KeyCode::Home; + match self.to_first { + true => self.screen.scroll(isize::MAX), + false => self.screen.latest(), + } + } // Sending goes back to the newest rows, where its answer comes. KeyCode::Enter => { self.screen.latest(); @@ -7546,6 +7667,29 @@ fn responses(item: &Value) -> Vec { mod tests { use super::*; + #[test] + fn a_page_only_read_gives_back_what_drawing_it_would() { + let call = |id: &str, name: &str, arguments: &str| json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}); + let result = |id: &str| json!({"type": "function_call_output", "call_id": id}); + let items = vec![ + (1, result("before")), + (2, call("ran", "shell", "{}")), + (3, result("ran")), + (4, call("here", "react", r#"{"emoji":"๐Ÿ‘"}"#)), + (5, call("there", "react", r#"{"emoji":"๐Ÿ‘","bot":"other"}"#)), + (6, result("here")), + (6, result("there")), + ]; + let after = vec![(9, result("newer"))]; + let back: Vec = unpaired(items.into_iter().chain(after)) + .into_iter() + .map(|(node, _)| node) + .collect(); + // A result before its call, one for another agent's reaction, and + // one a newer page could not pair are for older pages. + assert_eq!(back, vec![1, 6, 9]); + } + #[test] fn another_projects_lead_and_its_tasks_are_never_tasks_here() { let lineage: std::collections::HashSet = ["ws.lead".into(), "ws.fix".into()].into(); diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index b063d43e9..47793e148 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -320,6 +320,35 @@ impl Screen { self.let_go(); } + /// Blank rows above the first row standing in for `pages`, from the + /// first, each as many rows as it says, until the view nears it. + pub fn prepend_blank(&mut self, pages: Vec<(u64, usize)>) { + if pages.is_empty() { + return; + } + let pages: Vec = (pages.into_iter()) + .map(|(id, rows)| Page { + id, + rows: rows.max(1), + drawn: false, + }) + .collect(); + let rows = pages.iter().map(|page| page.rows).sum(); + // One page as tall as them all, then each its own. + self.pages.insert( + 0, + Page { + id: 0, + rows: 0, + drawn: false, + }, + ); + self.placed = None; + self.swap(0, vec![Line::default(); rows]); + self.pages.splice(0..1, pages); + self.placed = None; + } + /// The page `id`, let go before, drawn again with `lines`. pub fn fill(&mut self, id: u64, lines: Vec>) { let Some(page) = self.pages.iter().position(|p| p.id == id && !p.drawn) else { @@ -339,42 +368,6 @@ impl Screen { .collect() } - /// Blank rows over the rest for each of `pages`, as tall as it was, - /// to be drawn again as the view nears it: the view stays as many - /// rows up from the newest. - pub fn blank(&mut self, pages: Vec<(u64, usize)>) { - let blank = Arc::new(Line::default()); - let rows: usize = pages.iter().map(|(_, rows)| rows).sum(); - self.rows.splice( - 0..0, - (0..rows).map(|_| Row { - line: blank.clone(), - rest: None, - link: None, - }), - ); - // Rows wrapped already are wrapped again under them. - self.shown.clear(); - self.wrapped = 0; - self.relaid = 0; - self.selection = None; - self.linked = None; - for spot in [&mut self.go_to, &mut self.keep].into_iter().flatten() { - spot.0 += rows; - } - if let Some(top) = &mut self.top_row { - *top += rows; - } - let pages = pages.into_iter().map(|(id, rows)| Page { - id, - rows, - drawn: false, - }); - self.pages.splice(0..0, pages); - self.placed = None; - self.laid += 1; - } - /// A page let go that the view is near, the nearest: to draw again. pub fn wanted(&mut self) -> Option { let height = self.area.height as usize; @@ -1944,7 +1937,7 @@ mod tests { // rows as tall as they were, the view on the newest. screen.clear(); screen.push(lines(20_000..20_050, "new")); - screen.blank(pages); + screen.prepend_blank(pages); let buf = drawn(&mut screen, area); assert_eq!(row(&buf, 4).trim_end(), "new 20049"); assert_eq!(screen.height(20), 9_050); @@ -1964,6 +1957,27 @@ mod tests { assert_eq!(row(&buf, 0).trim_end(), "row 0"); } + #[test] + fn pages_put_above_blank_wait_for_the_view_and_are_drawn_in_place() { + let area = Rect::new(0, 0, 20, 5); + let (mut screen, _) = screen(rows(20_000..20_050), 20, 5); + drawn(&mut screen, area); + // Guessed at 100 rows each; the view goes up to the first. + screen.prepend_blank(vec![(1, 100), (2, 100), (3, 100)]); + let buf = drawn(&mut screen, area); + assert_eq!(row(&buf, 4).trim_end(), "row 20049"); + assert_eq!(screen.height(20), 350); + screen.scroll(isize::MAX); + drawn(&mut screen, area); + assert_eq!(screen.wanted(), Some(1)); + // Drawn shorter than guessed, at the top all the same. + screen.fill(1, rows(0..30)); + let buf = drawn(&mut screen, area); + assert_eq!(row(&buf, 0).trim_end(), "row 0"); + assert_eq!(screen.height(20), 280); + assert_eq!(screen.wanted(), Some(2)); + } + #[test] fn a_picked_line_is_kept_in_view_with_rows_under_it() { let lines: Vec<_> = (0..20).map(|i| Line::from(format!("row {i}"))).collect(); From 41db643d47e5fc6c6cc6db022975777b7419ef1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:37:40 +0000 Subject: [PATCH 2/6] Sonny: Ctrl+F steps through the pages let go on the way to the next place Stepping older or newer, the pages let go between the place found and the next one are drawn again first, the nearest first, each looked through once, so what they hold is found too. The find bar says it is looking further meanwhile, and Esc stops it. A page drawn again is not let go at once to make room. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/src/main.rs | 38 +++++++++++++++++++++--- sonny/src/screen.rs | 72 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/sonny/src/main.rs b/sonny/src/main.rs index c45f9868a..34a1343a2 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -1662,6 +1662,9 @@ struct App { /// How many rows the pages drawn took, and for how many items: how /// tall a page not drawn yet is guessed to be. page_rows: (usize, usize), + /// Ctrl+F steps to the next place, older or newer, once the pages let + /// go before it are drawn again: those looked through so far. + find_step: Option<(bool, Vec)>, /// The window's width, the pane's included. full_width: u16, /// The pane beside the conversation on the full screen, with what the @@ -2324,6 +2327,7 @@ impl App { short_read: std::cell::Cell::new(false), to_first: false, page_rows: (0, 0), + find_step: None, full_width: 0, pane: None, pane_keys: false, @@ -2552,6 +2556,28 @@ impl App { self.woken.notify_one(); } } + if let Some((older, looked)) = &mut self.find_step { + let older = *older; + match (self.screen.find_gap(older, looked)).filter(|_| self.finding.is_some()) { + // A page at a time, the screen drawn between. + Some(page) => { + looked.push(page); + self.draw_again(page).await; + // A read cut short steps over what it did not draw. + match self.short_read.get() { + true => { + self.find_step = None; + self.screen.find_next(older); + } + false => self.woken.notify_one(), + } + } + None => { + self.find_step = None; + self.screen.find_next(older); + } + } + } // A page let go drawn again before the view reaches it, or // once the way up to the first turn is done. if full() @@ -5173,10 +5199,12 @@ impl App { match key.code { KeyCode::Esc => self.finding = None, _ if copy => self.finding = None, - KeyCode::Up | KeyCode::Enter => self.screen.find_next(true), - KeyCode::Char('f' | 'p') if ctrl => self.screen.find_next(true), - KeyCode::Down => self.screen.find_next(false), - KeyCode::Char('n') if ctrl => self.screen.find_next(false), + // The pages let go on the way drawn first, so what they + // hold is found too. + KeyCode::Up | KeyCode::Enter => self.find_step = Some((true, Vec::new())), + KeyCode::Char('f' | 'p') if ctrl => self.find_step = Some((true, Vec::new())), + KeyCode::Down => self.find_step = Some((false, Vec::new())), + KeyCode::Char('n') if ctrl => self.find_step = Some((false, Vec::new())), KeyCode::Backspace => { query.pop(); self.screen.find(query); @@ -5190,6 +5218,7 @@ impl App { _ => {} } if self.finding.is_none() { + self.find_step = None; self.screen.end_find(); } return true; @@ -6888,6 +6917,7 @@ impl App { frame.render_widget(Paragraph::new(line), composer); frame.set_cursor_position((composer.x + 5 + query.width() as u16, composer.y)); let place = match self.screen.found() { + _ if self.find_step.is_some() => " looking further ยท ".to_owned(), Some((_, 0)) if !query.is_empty() => " not found ยท ".to_owned(), Some((at, count)) if count > 0 => format!(" {at} of {count} ยท "), _ => " ".to_owned(), diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index 47793e148..ab2beed83 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -317,7 +317,7 @@ impl Screen { ); self.placed = None; self.swap(0, lines); - self.let_go(); + self.let_go(None); } /// Blank rows above the first row standing in for `pages`, from the @@ -356,7 +356,7 @@ impl Screen { }; self.pages[page].drawn = true; self.swap(page, lines); - self.let_go(); + self.let_go(Some(page)); } /// The pages of older items, from the first, and how many rows each @@ -465,8 +465,9 @@ impl Screen { } /// Past what pages keep drawn, those farthest from the view let go, - /// each for blank rows as tall, so nothing moves. - fn let_go(&mut self) { + /// each for blank rows as tall, so nothing moves; never `kept`, just + /// drawn. + fn let_go(&mut self, kept: Option) { let placed = self.placed(); let drawn = |pages: &[Page]| -> usize { (pages.iter().zip(&placed)) @@ -483,7 +484,9 @@ impl Screen { let mut far: Vec = (0..self.pages.len()) .filter(|&p| { let at = &placed[p]; - self.pages[p].drawn && !(at.start < near.end && near.start < at.end) + self.pages[p].drawn + && Some(p) != kept + && !(at.start < near.end && near.start < at.end) }) .collect(); far.sort_by_key(|&p| std::cmp::Reverse(placed[p].start.abs_diff(bottom))); @@ -736,6 +739,40 @@ impl Screen { self.reveal(); } + /// A page let go between the place found and the next one, `older` + /// or newer, the nearest it, but those `looked` through already: to + /// draw before stepping, so what it holds is found too. + pub fn find_gap(&mut self, older: bool, looked: &[u64]) -> Option { + let finding = (self.finding.as_ref()).filter(|f| f.at != AGAIN && f.laid == self.laid)?; + let bottom = self.shown.len() - self.back.min(self.shown.len()); + let hits = &finding.hits; + let here = hits.get(finding.at).map_or(bottom, |(start, _)| start.0); + let (from, to) = match older { + true => ( + finding + .at + .checked_sub(1) + .and_then(|at| hits.get(at)) + .map_or(0, |hit| hit.0.0), + here, + ), + false => ( + here, + hits.get(finding.at + 1) + .map_or(self.shown.len(), |hit| hit.0.0), + ), + }; + let placed = self.placed(); + let gaps = (self.pages.iter().zip(placed)) + .filter(|(page, at)| !page.drawn && at.start < to && from < at.end) + .filter(|(page, _)| !looked.contains(&page.id)); + let nearest = match older { + true => gaps.max_by_key(|(_, at)| at.start), + false => gaps.min_by_key(|(_, at)| at.start), + }; + nearest.map(|(page, _)| page.id) + } + /// Stop finding, the view left where it is. pub fn end_find(&mut self) { self.finding = None; @@ -1978,6 +2015,31 @@ mod tests { assert_eq!(screen.wanted(), Some(2)); } + #[test] + fn find_draws_the_pages_let_go_on_the_way_to_the_next_place() { + let area = Rect::new(0, 0, 20, 200); + let (mut screen, _) = screen(lines(0..50, "tail"), 20, 200); + screen.prepend(1, lines(0..7_000, "middle")); + screen.prepend(2, lines(0..7_000, "old")); + drawn(&mut screen, area); + assert!(!screen.pages[0].drawn && screen.pages[1].drawn); + screen.find("old 1"); + drawn(&mut screen, area); + // Nothing found in the rows drawn; older, the page let go is. + assert_eq!(screen.found(), Some((0, 0))); + assert_eq!(screen.find_gap(false, &[]), None); + assert_eq!(screen.find_gap(true, &[]), Some(2)); + screen.fill(2, lines(0..7_000, "old")); + drawn(&mut screen, area); + assert!(screen.pages[0].drawn); + assert_eq!(screen.find_gap(true, &[2]), None); + screen.find_next(true); + let buf = drawn(&mut screen, area); + assert!((0..200).any(|y| row(&buf, y).starts_with("old 1"))); + // Looked through already, a page let go again is not again. + assert_eq!(screen.find_gap(true, &[2]), None); + } + #[test] fn a_picked_line_is_kept_in_view_with_rows_under_it() { let lines: Vec<_> = (0..20).map(|i| Line::from(format!("row {i}"))).collect(); From 77d0fe7b5e314d9f3b257bdcdf6b9990b7fe450e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:44:05 +0000 Subject: [PATCH 3/6] Sonny: Home finds the first turn after the oldest rows went to make room Once the rows a long window wrote passed what is kept, the oldest went and the rows over them were no longer asked for, so Home stopped there. Now Home draws the conversation anew from its newest page and goes up from there. A repaint drawn from the newest page puts the welcome over it only once it is known to start the conversation, as attaching does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/src/main.rs | 50 ++++++++++++++++++++++++++++++--------------- sonny/src/screen.rs | 5 +++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 34a1343a2..849f62cd1 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -2381,16 +2381,7 @@ impl App { self.reactions = reactions::Reactions::load(self.reactions_key.as_deref()); self.picked = reactions::Picked::load(self.reactions_key.as_deref()); self.history().await; - // Known to start the conversation only once its newest page is - // read, the welcome goes above it. - let mut welcome = self.welcome(); - if !welcome.is_empty() { - if self.any_history { - welcome.push(Line::default()); - } - self.any_history = true; - self.pending.splice(0..0, welcome); - } + self.welcome_above(); // A turn already running: its clock, and any call it waits on. if let Some(turn) = self.running.as_ref().map(|r| r.turn) { status_report("working", &self.bot); @@ -2550,9 +2541,10 @@ impl App { } if self.to_first { self.screen.scroll(isize::MAX); - self.to_first = full() && self.older.is_some() && self.screen.near_top(); + self.to_first = + full() && (self.repaint || self.older.is_some() && self.screen.near_top()); // The next page asked for once this one is drawn, not a tick on. - if self.to_first { + if self.to_first && !self.repaint { self.woken.notify_one(); } } @@ -2921,12 +2913,17 @@ impl App { self.screen.prepend_blank(pages); return Ok(()); } - let welcome = self.welcome(); - self.commit(welcome); // Back to the first node this window drew, not only the newest page. match self.first_node { - Some(first) => self.history_since(first - 1, true).await, - None => self.history().await, + Some(first) => { + let welcome = self.welcome(); + self.commit(welcome); + self.history_since(first - 1, true).await; + } + None => { + self.history().await; + self.welcome_above(); + } } Ok(()) } @@ -3220,6 +3217,19 @@ impl App { /// Commit a finished cell, a blank line apart from the one before. /// The welcome, when this window has one and draws the conversation /// from its first item: none above a page with older ones before it. + /// The welcome over the newest page just read, known to start the + /// conversation only once it is. + fn welcome_above(&mut self) { + let mut welcome = self.welcome(); + if !welcome.is_empty() { + if self.any_history { + welcome.push(Line::default()); + } + self.any_history = true; + self.pending.splice(0..0, welcome); + } + } + fn welcome(&self) -> Vec> { match self.welcome && self.older.is_none() { true => banner::welcome(&tilde(&self.workspace), &self.model, self.width), @@ -5258,6 +5268,14 @@ impl App { { self.to_first = key.code == KeyCode::Home; match self.to_first { + // The oldest rows went to make room: drawn anew from + // the newest page, the way up starts over. + true if self.screen.trimmed() => { + self.first_node = None; + self.older = None; + self.unpaired.clear(); + self.repaint = true; + } true => self.screen.scroll(isize::MAX), false => self.screen.latest(), } diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index ab2beed83..425878c0c 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -294,6 +294,11 @@ impl Screen { self.back = back; } + /// The oldest rows went to make room, and those above are not drawn. + pub fn trimmed(&self) -> bool { + self.trimmed + } + /// The view is a few screens from the first row, or nearer, and the /// rows above it can still be drawn: time to ask for older ones. pub fn near_top(&self) -> bool { From 051e148831142602441cee9a5f6f198ee76010fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:53:00 +0000 Subject: [PATCH 4/6] Sonny: Ctrl+F looks round past the last place; a failed read stops the way up Stepping past the newest (or oldest) place now also looks through the pages let go on the far side, the way finding goes round in the rows drawn. A failed history read during the walk to the first turn stops the walk instead of asking again at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/src/main.rs | 2 + sonny/src/screen.rs | 92 +++++++++++++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 849f62cd1..ba3239a76 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -2950,6 +2950,8 @@ impl App { Ok(page) => page, Err(error) => { self.notice = Some(error.to_string()); + // Not asked again at once: the way up stops. + self.to_first = false; break; } }; diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index 425878c0c..5a3dc75ba 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -745,37 +745,49 @@ impl Screen { } /// A page let go between the place found and the next one, `older` - /// or newer, the nearest it, but those `looked` through already: to - /// draw before stepping, so what it holds is found too. + /// or newer, round past the last as stepping goes, the nearest it, + /// but those `looked` through already: to draw before stepping, so + /// what it holds is found too. pub fn find_gap(&mut self, older: bool, looked: &[u64]) -> Option { let finding = (self.finding.as_ref()).filter(|f| f.at != AGAIN && f.laid == self.laid)?; let bottom = self.shown.len() - self.back.min(self.shown.len()); - let hits = &finding.hits; - let here = hits.get(finding.at).map_or(bottom, |(start, _)| start.0); - let (from, to) = match older { - true => ( - finding - .at - .checked_sub(1) - .and_then(|at| hits.get(at)) - .map_or(0, |hit| hit.0.0), - here, - ), - false => ( - here, - hits.get(finding.at + 1) - .map_or(self.shown.len(), |hit| hit.0.0), - ), + let starts: Vec = finding.hits.iter().map(|(start, _)| start.0).collect(); + let at = finding.at; + let here = starts.get(at).copied().unwrap_or(bottom); + let end = self.shown.len(); + // Up to the next place; past the last, round from the other end. + let ranges = match older { + true => [ + ( + at.checked_sub(1) + .and_then(|at| starts.get(at)) + .map_or(0, |s| *s), + here, + ), + match at.checked_sub(1) { + Some(_) => (end, end), + None => (starts.last().copied().unwrap_or(here), end), + }, + ], + false => [ + (here, starts.get(at + 1).copied().unwrap_or(end)), + match starts.get(at + 1) { + Some(_) => (0, 0), + None => (0, starts.first().copied().unwrap_or(here)), + }, + ], }; let placed = self.placed(); - let gaps = (self.pages.iter().zip(placed)) - .filter(|(page, at)| !page.drawn && at.start < to && from < at.end) - .filter(|(page, _)| !looked.contains(&page.id)); - let nearest = match older { - true => gaps.max_by_key(|(_, at)| at.start), - false => gaps.min_by_key(|(_, at)| at.start), - }; - nearest.map(|(page, _)| page.id) + ranges.into_iter().find_map(|(from, to)| { + let gaps = (self.pages.iter().zip(&placed)) + .filter(|(page, at)| !page.drawn && at.start < to && from < at.end) + .filter(|(page, _)| !looked.contains(&page.id)); + let nearest = match older { + true => gaps.max_by_key(|(_, at)| at.start), + false => gaps.min_by_key(|(_, at)| at.start), + }; + nearest.map(|(page, _)| page.id) + }) } /// Stop finding, the view left where it is. @@ -2030,9 +2042,9 @@ mod tests { assert!(!screen.pages[0].drawn && screen.pages[1].drawn); screen.find("old 1"); drawn(&mut screen, area); - // Nothing found in the rows drawn; older, the page let go is. + // Nothing found in the rows drawn; either way round, the page let go is. assert_eq!(screen.found(), Some((0, 0))); - assert_eq!(screen.find_gap(false, &[]), None); + assert_eq!(screen.find_gap(false, &[]), Some(2)); assert_eq!(screen.find_gap(true, &[]), Some(2)); screen.fill(2, lines(0..7_000, "old")); drawn(&mut screen, area); @@ -2045,6 +2057,30 @@ mod tests { assert_eq!(screen.find_gap(true, &[2]), None); } + #[test] + fn find_goes_round_through_the_pages_let_go_past_the_last_place() { + let area = Rect::new(0, 0, 20, 200); + let (mut screen, _) = screen(lines(0..50, "tail"), 20, 200); + screen.prepend(1, lines(0..7_000, "middle")); + screen.prepend(2, lines(0..7_000, "tail")); + drawn(&mut screen, area); + assert!(!screen.pages[0].drawn); + screen.find("tail 7"); + drawn(&mut screen, area); + assert_eq!(screen.found(), Some((1, 1))); + // Newer than the newest place, round from the first row. + assert_eq!(screen.find_gap(false, &[]), Some(2)); + // Older than the oldest, round from the newest row: none below. + assert_eq!(screen.find_gap(true, &[]), Some(2)); + screen.fill(2, lines(0..7_000, "tail")); + drawn(&mut screen, area); + assert_eq!(screen.find_gap(false, &[2]), None); + screen.find_next(false); + let buf = drawn(&mut screen, area); + assert!((0..200).any(|y| row(&buf, y).trim_end() == "tail 7")); + assert_eq!(screen.found().map(|(at, _)| at), Some(1)); + } + #[test] fn a_picked_line_is_kept_in_view_with_rows_under_it() { let lines: Vec<_> = (0..20).map(|i| Line::from(format!("row {i}"))).collect(); From b5ad3810d81438d38b6ec7ffe3819868dcbb2685 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:56:35 +0000 Subject: [PATCH 5/6] Sonny: Ctrl+F from nothing found lands on the nearest place that way With no place found yet, the pages drawn on the way leave the position past the last place. Stepping then picks the nearest place to the view in the direction asked, and the pages still to draw are looked for from the view too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/src/screen.rs | 61 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index 5a3dc75ba..d01d6ce23 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -730,6 +730,7 @@ impl Screen { /// The next place the text found is: older, or newer, round to the /// other end past the last. pub fn find_next(&mut self, older: bool) { + let bottom = self.shown.len() - self.back.min(self.shown.len()); let Some(finding) = &mut self.finding else { return; }; @@ -737,9 +738,14 @@ impl Screen { if count == 0 { return; } - finding.at = match older { - true => (finding.at + count - 1) % count, - false => (finding.at + 1) % count, + let above = (finding.hits).partition_point(|(start, _)| start.0 < bottom); + finding.at = match (older, finding.at < count) { + (true, true) => (finding.at + count - 1) % count, + (false, true) => (finding.at + 1) % count, + // No place yet, as when none was until pages were drawn: the + // nearest the view, that way. + (true, false) => (above + count - 1) % count, + (false, false) => above % count, }; self.reveal(); } @@ -753,25 +759,32 @@ impl Screen { let bottom = self.shown.len() - self.back.min(self.shown.len()); let starts: Vec = finding.hits.iter().map(|(start, _)| start.0).collect(); let at = finding.at; - let here = starts.get(at).copied().unwrap_or(bottom); + // The place shown and those either side; with none, the view's. + let (here, prev, next) = match starts.get(at) { + Some(&here) => ( + here, + at.checked_sub(1).map(|at| starts[at]), + starts.get(at + 1).copied(), + ), + None => ( + bottom, + starts.iter().rev().find(|s| **s < bottom).copied(), + starts.iter().find(|s| **s >= bottom).copied(), + ), + }; let end = self.shown.len(); // Up to the next place; past the last, round from the other end. let ranges = match older { true => [ - ( - at.checked_sub(1) - .and_then(|at| starts.get(at)) - .map_or(0, |s| *s), - here, - ), - match at.checked_sub(1) { + (prev.unwrap_or(0), here), + match prev { Some(_) => (end, end), None => (starts.last().copied().unwrap_or(here), end), }, ], false => [ - (here, starts.get(at + 1).copied().unwrap_or(end)), - match starts.get(at + 1) { + (here, next.unwrap_or(end)), + match next { Some(_) => (0, 0), None => (0, starts.first().copied().unwrap_or(here)), }, @@ -2057,6 +2070,28 @@ mod tests { assert_eq!(screen.find_gap(true, &[2]), None); } + #[test] + fn find_from_nothing_found_lands_on_the_nearest_place_that_way() { + let area = Rect::new(0, 0, 20, 200); + for (older, place) in [(false, 1), (true, 11)] { + let (mut screen, _) = screen(lines(0..50, "tail"), 20, 200); + screen.prepend(1, lines(0..7_000, "middle")); + screen.prepend(2, lines(0..7_000, "old")); + drawn(&mut screen, area); + screen.find("old 699"); + drawn(&mut screen, area); + assert_eq!(screen.found(), Some((0, 0))); + assert_eq!(screen.find_gap(older, &[]), Some(2)); + screen.fill(2, lines(0..7_000, "old")); + drawn(&mut screen, area); + assert_eq!(screen.find_gap(older, &[2]), None); + // Newer than the view, round from the first row: the oldest + // place; older, the newest above it. + screen.find_next(older); + assert_eq!(screen.found(), Some((place, 11))); + } + } + #[test] fn find_goes_round_through_the_pages_let_go_past_the_last_place() { let area = Rect::new(0, 0, 20, 200); From 8115a77b115aeee734b4da3fb1e33359db490a9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:03:16 +0000 Subject: [PATCH 6/6] Sonny: a Ctrl+F step stops for new text, empty text, and keeps its page Typing or pasting into the find bar ends a step still looking for the last text. Nothing typed draws no pages. While a step is under way, the pages near the view wait, so the one just looked through is not let go before its place is shown. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGDkoLb9JSjmwGBPeUi --- sonny/src/main.rs | 8 +++++++- sonny/src/screen.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sonny/src/main.rs b/sonny/src/main.rs index ba3239a76..07dad67a3 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -2571,9 +2571,11 @@ impl App { } } // A page let go drawn again before the view reaches it, or - // once the way up to the first turn is done. + // once the way up to the first turn, or a step finding, is + // done: never letting go a page found in before it is shown. if full() && !self.to_first + && self.find_step.is_none() && let Some(page) = self.screen.wanted() { self.draw_again(page).await; @@ -5001,6 +5003,7 @@ impl App { Event::Paste(text) if let Some(query) = &mut self.finding => { query.extend(text.chars().map(|c| if c.is_control() { ' ' } else { c })); self.screen.find(query); + self.find_step = None; return true; } Event::Mouse(mouse) => { @@ -5217,13 +5220,16 @@ impl App { KeyCode::Char('f' | 'p') if ctrl => self.find_step = Some((true, Vec::new())), KeyCode::Down => self.find_step = Some((false, Vec::new())), KeyCode::Char('n') if ctrl => self.find_step = Some((false, Vec::new())), + // Another text: a step for the last one goes no further. KeyCode::Backspace => { query.pop(); self.screen.find(query); + self.find_step = None; } KeyCode::Char(c) if !ctrl => { query.push(c); self.screen.find(query); + self.find_step = None; } KeyCode::PageUp => self.page_up(), KeyCode::PageDown => self.screen.page(false), diff --git a/sonny/src/screen.rs b/sonny/src/screen.rs index d01d6ce23..cb8d60b4a 100644 --- a/sonny/src/screen.rs +++ b/sonny/src/screen.rs @@ -755,7 +755,9 @@ impl Screen { /// but those `looked` through already: to draw before stepping, so /// what it holds is found too. pub fn find_gap(&mut self, older: bool, looked: &[u64]) -> Option { - let finding = (self.finding.as_ref()).filter(|f| f.at != AGAIN && f.laid == self.laid)?; + // Nothing looked for, nothing to draw for it. + let finding = (self.finding.as_ref()) + .filter(|f| !f.query.is_empty() && f.at != AGAIN && f.laid == self.laid)?; let bottom = self.shown.len() - self.back.min(self.shown.len()); let starts: Vec = finding.hits.iter().map(|(start, _)| start.0).collect(); let at = finding.at; @@ -2078,6 +2080,10 @@ mod tests { screen.prepend(1, lines(0..7_000, "middle")); screen.prepend(2, lines(0..7_000, "old")); drawn(&mut screen, area); + // Nothing typed yet, nothing to draw for it. + screen.find(""); + drawn(&mut screen, area); + assert_eq!(screen.find_gap(older, &[]), None); screen.find("old 699"); drawn(&mut screen, area); assert_eq!(screen.found(), Some((0, 0)));