From aa048a6681054bcfb71b72a432f5786a6b9e7f4c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 9 Jul 2026 14:27:27 -0400 Subject: [PATCH 1/3] th-d5b446: th code TUI conversation sidebar (resume + new) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a togglable conversation sidebar to the `th code` TUI for parity with the daemon PWA sidebar. Ctrl+B opens a left-anchored overlay (same pattern as the model picker — inline-viewport mode has no persistent pane) listing saved coding sessions newest-first with the active session highlighted, plus a "New conversation" entry. Built on the existing local session store (crate::session:: SessionManager) — Enter resumes the selected session by loading its history into the live AppState via the existing resume path, or starts a fresh conversation; `n` shortcuts new-chat; Esc closes. Current session is saved before switching so nothing is lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/th-code-conversation-sidebar.md | 5 + crates/smooth-code/src/app.rs | 113 +++++++++++++-- crates/smooth-code/src/lib.rs | 1 + crates/smooth-code/src/render.rs | 110 ++++++++++++++- crates/smooth-code/src/session_picker.rs | 157 +++++++++++++++++++++ crates/smooth-code/src/state.rs | 41 ++++++ 6 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 .changeset/th-code-conversation-sidebar.md create mode 100644 crates/smooth-code/src/session_picker.rs diff --git a/.changeset/th-code-conversation-sidebar.md b/.changeset/th-code-conversation-sidebar.md new file mode 100644 index 00000000..3689d94d --- /dev/null +++ b/.changeset/th-code-conversation-sidebar.md @@ -0,0 +1,5 @@ +--- +"@smooai/smooth": minor +--- + +`th code` TUI conversation sidebar. Ctrl+B toggles a left-anchored overlay listing saved coding sessions (newest-first, the active one highlighted) with a "New conversation" entry at the top. Enter resumes the selected session (loads its history into the chat view via the existing `SessionManager` store) or starts a fresh one; `n` is a new-chat shortcut; Esc closes. Parity with the daemon PWA sidebar. diff --git a/crates/smooth-code/src/app.rs b/crates/smooth-code/src/app.rs index a0b8cb5b..49cfff9b 100644 --- a/crates/smooth-code/src/app.rs +++ b/crates/smooth-code/src/app.rs @@ -470,17 +470,17 @@ fn event_loop( if let Event::Key(key) = evt { let mut s = state.lock().unwrap_or_else(|e| e.into_inner()); - // Global keybindings. Ctrl+B used to toggle the - // sidebar, but inline-viewport mode has no panel - // for one — the file tree / git pane / etc. that - // used to live there are reachable via slash - // commands (`/git`, future `/files`). The key is - // intentionally left unbound rather than re-purposed - // so muscle memory doesn't fire something - // unexpected. + // Global keybindings. Ctrl+C quits; Ctrl+B toggles the + // conversation sidebar (list saved sessions → resume / + // new). The sidebar is an overlay popup, not a + // persistent pane, because inline-viewport mode has no + // room for one — finalized chat lives in the terminal's + // own scrollback. if key.modifiers.contains(KeyModifiers::CONTROL) { - if let KeyCode::Char('c') = key.code { - s.should_quit = true; + match key.code { + KeyCode::Char('c') => s.should_quit = true, + KeyCode::Char('b') => toggle_session_sidebar(&mut s), + _ => {} } } @@ -505,6 +505,91 @@ fn event_loop( Ok(()) } +/// Toggle the conversation sidebar. Opening it saves the current +/// session first (so it shows up in — and is highlighted within — the +/// freshly-listed set) and loads the summaries from the on-disk +/// [`SessionManager`] store. A store error just yields an empty list +/// (the "New conversation" row still works). +fn toggle_session_sidebar(state: &mut AppState) { + if state.session_picker.active { + state.session_picker.deactivate(); + return; + } + let sessions = match SessionManager::new() { + Ok(mgr) => { + // Persist the current session so resuming back into it + // later doesn't lose in-flight history, and so it appears + // in the list we're about to show. + if !state.messages.is_empty() { + let _ = mgr.save(&Session::from_state(state)); + } + mgr.list().unwrap_or_default() + } + Err(_) => Vec::new(), + }; + let current_id = state.session_id.clone(); + state.session_picker.open(sessions, ¤t_id); +} + +/// Handle a key while the conversation sidebar owns the keyboard. +/// Returns `true` when the key was consumed. +fn handle_session_sidebar_key(key: event::KeyEvent, state: &mut AppState) -> bool { + use crate::session_picker::PickerAction; + + match key.code { + KeyCode::Up => state.session_picker.select_up(), + KeyCode::Down => state.session_picker.select_down(), + KeyCode::Esc => state.session_picker.deactivate(), + KeyCode::Char('n') => { + // Shortcut for the "New conversation" entry regardless of + // cursor position. + save_current_session(state); + state.start_new_conversation(); + state.add_message(ChatMessage::system("Started a new conversation. Type a message to get going.")); + state.session_picker.deactivate(); + } + KeyCode::Enter => { + match state.session_picker.selected_action() { + PickerAction::New => { + save_current_session(state); + state.start_new_conversation(); + state.add_message(ChatMessage::system("Started a new conversation. Type a message to get going.")); + } + PickerAction::Resume(id) => { + if id == state.session_id { + // Already viewing it — just close. + } else if let Ok(mgr) = SessionManager::new() { + save_current_session(state); + match mgr.load(&id) { + Ok(session) => { + state.resume_from(&session); + let label = state.session_title.clone().unwrap_or_else(|| id.clone()); + state.add_message(ChatMessage::system(format!("Resumed session: {label}"))); + } + Err(e) => { + state.add_message(ChatMessage::system(format!("Could not load session {id}: {e}"))); + } + } + } + } + } + state.session_picker.deactivate(); + } + _ => {} + } + true +} + +/// Best-effort save of the current session to the on-disk store. +fn save_current_session(state: &AppState) { + if state.messages.is_empty() { + return; + } + if let Ok(mgr) = SessionManager::new() { + let _ = mgr.save(&Session::from_state(state)); + } +} + /// Map an `AgentEvent` to the appropriate state mutation. fn handle_agent_event(state: &mut AppState, event: AgentEvent) { match event { @@ -705,6 +790,14 @@ fn handle_input_mode( } } + // Conversation sidebar owns the keyboard while it's visible. + // Up/Down navigates, Enter resumes (or starts a new chat on the + // "New conversation" row), `n` is a new-chat shortcut, Esc closes. + if state.session_picker.active { + handle_session_sidebar_key(key, state); + return; + } + // Model picker owns the keyboard while it's visible. Up/Down // navigates, Enter drills in or applies, Esc backs out (Models → // Slots → closed). diff --git a/crates/smooth-code/src/lib.rs b/crates/smooth-code/src/lib.rs index c1d34512..561e4f90 100644 --- a/crates/smooth-code/src/lib.rs +++ b/crates/smooth-code/src/lib.rs @@ -24,6 +24,7 @@ pub mod permissions; pub mod render; pub mod sep_host; pub mod session; +pub mod session_picker; pub mod state; pub mod theme; pub mod thesaurus; diff --git a/crates/smooth-code/src/render.rs b/crates/smooth-code/src/render.rs index 62c01a0f..ee2b8f03 100644 --- a/crates/smooth-code/src/render.rs +++ b/crates/smooth-code/src/render.rs @@ -65,6 +65,92 @@ pub fn render(frame: &mut Frame, state: &AppState) { if state.model_picker.active { render_model_picker(frame, state, area); } + + if state.session_picker.active { + render_session_sidebar(frame, state, area); + } +} + +/// Render the conversation sidebar — a left-anchored overlay listing +/// saved sessions (newest-first) plus a "New conversation" entry at the +/// top. The active session and the cursor row are highlighted. +fn render_session_sidebar(frame: &mut Frame, state: &AppState, area: Rect) { + let picker = &state.session_picker; + + let width = 44u16.min(area.width.saturating_sub(2)); + if width < 6 || area.height < 4 { + return; // Terminal too small to render a usable panel. + } + + // +2 border, +1 footer. Cap to the frame height. + #[allow(clippy::cast_possible_truncation)] + let rows = picker.row_count().min(usize::from(u16::MAX) - 4) as u16; + let height = (rows + 3).min(area.height.saturating_sub(1)).max(4); + + // Left-anchored, vertically centered — reads as a sidebar without a + // persistent pane in inline-viewport mode. + let [col] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Start).areas(area); + let [panel] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(col); + + frame.render_widget(Clear, panel); + let block = Block::default().title(" Conversations ").borders(Borders::ALL).border_style(theme::title()); + let inner = block.inner(panel); + frame.render_widget(block, panel); + + let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner); + let list_area = chunks[0]; + let footer_area = chunks[1]; + let max_chars = usize::from(list_area.width).max(1); + + let mut items: Vec> = Vec::with_capacity(picker.row_count()); + // Row 0: the New-conversation entry. + items.push(session_row("+ New conversation", false, picker.selected == 0, max_chars)); + // Remaining rows: sessions, newest-first. + for (i, summary) in picker.sessions.iter().enumerate() { + let selected = picker.selected == i + 1; + let is_current = summary.id == picker.current_session_id; + let marker = if is_current { "● " } else { " " }; + let label = format!("{marker}{} · {}", summary.display_label(), relative_time(summary.updated_at)); + items.push(session_row(&label, is_current, selected, max_chars)); + } + frame.render_widget(List::new(items), list_area); + + frame.render_widget(Paragraph::new("↑/↓ select Enter resume n new Esc close").style(theme::muted()), footer_area); +} + +/// Build one sidebar list row, truncating to `max_chars` (char-safe) +/// and highlighting the selected row. The currently-active session +/// (non-selected) is tinted to stand out from the rest. +fn session_row(label: &str, is_current: bool, selected: bool, max_chars: usize) -> ListItem<'static> { + let prefix = if selected { "▸ " } else { " " }; + let raw = format!("{prefix}{label}"); + let text: String = if raw.chars().count() > max_chars && max_chars > 1 { + let cut: String = raw.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{cut}…") + } else { + raw + }; + if selected { + ListItem::new(Span::styled(text, Style::default().bg(theme::SMOO_ORANGE).fg(ratatui::style::Color::Black))) + } else if is_current { + ListItem::new(Span::styled(text, Style::default().fg(theme::SMOO_ORANGE))) + } else { + ListItem::new(Span::raw(text)) + } +} + +/// Compact "time ago" label for the sidebar (e.g. `3m`, `2h`, `5d`). +fn relative_time(when: chrono::DateTime) -> String { + let secs = (chrono::Utc::now() - when).num_seconds().max(0); + if secs < 60 { + "now".to_string() + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86_400 { + format!("{}h", secs / 3600) + } else { + format!("{}d", secs / 86_400) + } } /// Render the autocomplete popup directly above the input box. @@ -594,7 +680,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { state.total_tokens, format_spend(state.total_cost_usd), ); - let status_right = " | Ctrl+C quit "; + let status_right = " | Ctrl+B chats | Ctrl+C quit "; let line = Line::from(vec![ Span::styled(status_left, theme::status_style()), @@ -608,7 +694,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { } /// Render the sidebar panel with the file tree. -#[allow(dead_code)] // sidebar removed when switching to inline viewport (Ctrl+B unbound) +#[allow(dead_code)] // old file-tree sidebar; inline viewport dropped it. Ctrl+B now opens the conversation sidebar (session_picker). fn render_sidebar(frame: &mut Frame, state: &AppState, area: Rect) { let block = Block::default() .title(" Files ") @@ -860,3 +946,23 @@ mod spend_fmt_tests { assert_eq!(format_spend(12.345), "$12.35"); } } + +#[cfg(test)] +mod relative_time_tests { + use chrono::{Duration, Utc}; + + use super::relative_time; + + #[test] + fn buckets_by_magnitude() { + assert_eq!(relative_time(Utc::now() - Duration::seconds(10)), "now"); + assert_eq!(relative_time(Utc::now() - Duration::minutes(3)), "3m"); + assert_eq!(relative_time(Utc::now() - Duration::hours(2)), "2h"); + assert_eq!(relative_time(Utc::now() - Duration::days(5)), "5d"); + } + + #[test] + fn future_timestamps_clamp_to_now() { + assert_eq!(relative_time(Utc::now() + Duration::hours(1)), "now"); + } +} diff --git a/crates/smooth-code/src/session_picker.rs b/crates/smooth-code/src/session_picker.rs new file mode 100644 index 00000000..84f304a0 --- /dev/null +++ b/crates/smooth-code/src/session_picker.rs @@ -0,0 +1,157 @@ +//! Conversation sidebar — a togglable overlay listing saved coding +//! sessions so the user can resume one or start a fresh conversation +//! without leaving the TUI. Parity with the daemon PWA's sidebar. +//! +//! Inline-viewport mode has no persistent left pane (finalized +//! messages live in the terminal's own scrollback), so this rides the +//! same overlay-popup pattern as the model picker: while `active`, it +//! owns the keyboard and renders as a left-anchored panel over the +//! viewport. Backed by the existing [`crate::session::SessionManager`] +//! store — no new persistence layer. + +use crate::session::SessionSummary; + +/// What selecting the current row should do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PickerAction { + /// Start a fresh conversation. + New, + /// Resume the session with this id. + Resume(String), +} + +/// State for the conversation sidebar overlay. +#[derive(Debug, Default)] +pub struct SessionPickerState { + /// Whether the sidebar is currently shown (owns the keyboard). + pub active: bool, + /// Saved sessions, newest-first (as returned by `SessionManager::list`). + pub sessions: Vec, + /// Selected row. Row 0 is always the "New conversation" entry; + /// rows `1..=sessions.len()` map to `sessions[selected - 1]`. + pub selected: usize, + /// Id of the session currently loaded in the chat view, so the + /// list can highlight it. Empty when unknown. + pub current_session_id: String, +} + +impl SessionPickerState { + /// Create an inactive picker. + pub fn new() -> Self { + Self::default() + } + + /// Total selectable rows: the "New" entry plus every session. + pub fn row_count(&self) -> usize { + self.sessions.len() + 1 + } + + /// Open the sidebar with a freshly-listed set of sessions. The + /// cursor starts on the currently-active session when it's in the + /// list, otherwise on the "New conversation" row. + pub fn open(&mut self, sessions: Vec, current_session_id: &str) { + self.selected = sessions.iter().position(|s| s.id == current_session_id).map_or(0, |i| i + 1); + self.sessions = sessions; + self.current_session_id = current_session_id.to_string(); + self.active = true; + } + + /// Hide the sidebar. + pub fn deactivate(&mut self) { + self.active = false; + } + + /// Move the cursor up one row (saturating at the top). + pub fn select_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + /// Move the cursor down one row (saturating at the last session). + pub fn select_down(&mut self) { + if self.selected + 1 < self.row_count() { + self.selected += 1; + } + } + + /// The action for the currently-selected row. + pub fn selected_action(&self) -> PickerAction { + if self.selected == 0 { + PickerAction::New + } else { + // `select_down` clamps `selected` to a valid row, so the + // index is in range; fall back to New defensively. + self.sessions + .get(self.selected - 1) + .map_or(PickerAction::New, |s| PickerAction::Resume(s.id.clone())) + } + } +} + +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + + use super::*; + + fn summary(id: &str, minutes_ago: i64) -> SessionSummary { + SessionSummary { + id: id.to_string(), + title: Some(format!("title-{id}")), + preview: format!("preview-{id}"), + message_count: 2, + updated_at: Utc::now() - Duration::minutes(minutes_ago), + } + } + + #[test] + fn open_selects_current_session_row() { + let mut p = SessionPickerState::new(); + p.open(vec![summary("a", 1), summary("b", 5)], "b"); + // Row 0 = New, row 1 = a, row 2 = b → current "b" is row 2. + assert!(p.active); + assert_eq!(p.selected, 2); + assert_eq!(p.selected_action(), PickerAction::Resume("b".into())); + } + + #[test] + fn open_defaults_to_new_when_current_absent() { + let mut p = SessionPickerState::new(); + p.open(vec![summary("a", 1)], "not-in-list"); + assert_eq!(p.selected, 0); + assert_eq!(p.selected_action(), PickerAction::New); + } + + #[test] + fn navigation_saturates_at_both_ends() { + let mut p = SessionPickerState::new(); + p.open(vec![summary("a", 1), summary("b", 2)], ""); + // Starts at row 0 (New, since current id empty). Up saturates. + p.select_up(); + assert_eq!(p.selected, 0); + p.select_down(); + p.select_down(); + assert_eq!(p.selected, 2); // last session + p.select_down(); // saturates at last row + assert_eq!(p.selected, 2); + } + + #[test] + fn row_zero_is_always_new() { + let mut p = SessionPickerState::new(); + p.open(vec![summary("a", 1)], "a"); + p.select_up(); + p.select_up(); + assert_eq!(p.selected, 0); + assert_eq!(p.selected_action(), PickerAction::New); + } + + #[test] + fn empty_store_only_has_new_row() { + let mut p = SessionPickerState::new(); + p.open(vec![], ""); + assert_eq!(p.row_count(), 1); + p.select_down(); + assert_eq!(p.selected, 0); + assert_eq!(p.selected_action(), PickerAction::New); + } +} diff --git a/crates/smooth-code/src/state.rs b/crates/smooth-code/src/state.rs index 5b1f2b58..17299132 100644 --- a/crates/smooth-code/src/state.rs +++ b/crates/smooth-code/src/state.rs @@ -376,6 +376,9 @@ pub struct AppState { /// the most recently filed open prompt by POSTing to /// `/api/access/{approve,deny}`. pub permission_prompts: Vec, + /// Conversation sidebar overlay state — lists saved sessions for + /// resume / new-chat. Toggled with Ctrl+B. + pub session_picker: crate::session_picker::SessionPickerState, } impl AppState { @@ -416,6 +419,7 @@ impl AppState { model_picker: ModelPickerState::new(), health_status: HealthStatus::default(), permission_prompts: Vec::new(), + session_picker: crate::session_picker::SessionPickerState::new(), } } @@ -439,6 +443,43 @@ impl AppState { state } + /// Swap the live conversation for a persisted one, in place. Used + /// by the conversation sidebar to resume a session without tearing + /// down the TUI. Working dir, model picker catalog, and pearls are + /// preserved; the chat history, ids, model, agent, and token count + /// come from `session`. + /// + /// `committed_count` resets to 0 so the resumed messages flush into + /// the terminal scrollback on the next tick. Prior scrollback stays + /// (the terminal owns it) — the resumed history simply appends + /// below, like re-running a program in the same terminal. + pub fn resume_from(&mut self, session: &crate::session::Session) { + self.session_id = session.id.clone(); + self.session_title = session.title.clone(); + self.messages = session.messages.iter().map(|m| m.to_chat_message()).collect(); + self.committed_count = 0; + self.model_name = session.model_name.clone(); + self.total_tokens = session.total_tokens; + if let Some(ref a) = session.agent_name { + self.agent_name = a.clone(); + self.agent_pinned = true; + } + self.thinking = false; + } + + /// Start a fresh conversation in place, keeping the working dir, + /// model, and agent selection. Used by the sidebar's "New + /// conversation" entry. + pub fn start_new_conversation(&mut self) { + self.session_id = Uuid::new_v4().to_string(); + self.session_title = None; + self.messages.clear(); + self.committed_count = 0; + self.total_tokens = 0; + self.total_cost_usd = 0.0; + self.thinking = false; + } + /// Add a message to the conversation history. pub fn add_message(&mut self, message: ChatMessage) { self.messages.push(message); From 76a866ed16327827e21eb2ccfa93282ec3ee87c0 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 9 Jul 2026 14:39:03 -0400 Subject: [PATCH 2/3] th-d5b446: fix rust-1.97 clippy useless_borrows_in_formatting in smooth-bench The CI runner bumped stable clippy to 1.97, whose clippy::all (deny) now flags a redundant `&` on a format! argument in pr_harvest.rs that 1.96 accepted. Pre-existing code, unrelated to the sidebar, but it fails the Clippy step for every PR against main. One-char fix: drop the redundant borrow. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-bench/src/pr_harvest.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/smooth-bench/src/pr_harvest.rs b/crates/smooth-bench/src/pr_harvest.rs index 79f49a48..50c43782 100644 --- a/crates/smooth-bench/src/pr_harvest.rs +++ b/crates/smooth-bench/src/pr_harvest.rs @@ -279,7 +279,7 @@ pub async fn harvest_prs_with(gh: &dyn GhCli, repo: &str, since: NaiveDate, limi let fetch_cap = (limit.saturating_mul(4)).min(500).max(limit); let json = gh.pr_list_json(repo, since, fetch_cap).await?; let raw_list: Vec = - serde_json::from_str(&json).with_context(|| format!("parsing gh pr list output (first 200 bytes: {})", &json.chars().take(200).collect::()))?; + serde_json::from_str(&json).with_context(|| format!("parsing gh pr list output (first 200 bytes: {})", json.chars().take(200).collect::()))?; let mut out: Vec = raw_list.into_iter().filter(is_eligible).filter_map(to_harvested).collect(); out.truncate(limit); Ok(out) From d765c2bb65ebb419d79f3d1561db3bc2a060848c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 9 Jul 2026 14:50:55 -0400 Subject: [PATCH 3/3] th-d5b446: fix remaining rust-1.97 clippy deny lints (bigsmooth, code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more pre-existing clippy::all (deny) lints newly raised by the CI runner's stable clippy bump to 1.97: - web_search.rs: question_mark — rewrite the quote-prefix if/else chain to strip_prefix().map().or_else()? - model_picker.rs test: useless_borrows_in_formatting — drop redundant & in an assert! format arg. Verified clean with `cargo +1.97 clippy --workspace --all-targets`. Unrelated to the sidebar; unblocks CI for every PR against main. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smooth-bigsmooth/src/web_search.rs | 11 ++++------- crates/smooth-code/src/model_picker.rs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/smooth-bigsmooth/src/web_search.rs b/crates/smooth-bigsmooth/src/web_search.rs index 98136aab..c32b3804 100644 --- a/crates/smooth-bigsmooth/src/web_search.rs +++ b/crates/smooth-bigsmooth/src/web_search.rs @@ -322,13 +322,10 @@ fn extract_attr(fragment: &str, name: &str) -> Option { let needle = format!("{name}="); let start = fragment.find(&needle)?; let after = &fragment[start + needle.len()..]; - let (quote, body) = if let Some(b) = after.strip_prefix('"') { - ('"', b) - } else if let Some(b) = after.strip_prefix('\'') { - ('\'', b) - } else { - return None; - }; + let (quote, body) = after + .strip_prefix('"') + .map(|b| ('"', b)) + .or_else(|| after.strip_prefix('\'').map(|b| ('\'', b)))?; let end = body.find(quote)?; Some(body[..end].to_string()) } diff --git a/crates/smooth-code/src/model_picker.rs b/crates/smooth-code/src/model_picker.rs index d12c62a3..7bac0d4c 100644 --- a/crates/smooth-code/src/model_picker.rs +++ b/crates/smooth-code/src/model_picker.rs @@ -1236,7 +1236,7 @@ mod tests { // and the result is stable/transitive — assert antisymmetry holds // across the whole set (the property the old comparator violated). for w in scores.windows(2) { - assert!(w[0].total_cmp(&w[1]) != std::cmp::Ordering::Less, "sorted descending: {:?}", &scores); + assert!(w[0].total_cmp(&w[1]) != std::cmp::Ordering::Less, "sorted descending: {:?}", scores); } }