Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/th-code-conversation-sidebar.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion crates/smooth-bench/src/pr_harvest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RawPr> =
serde_json::from_str(&json).with_context(|| format!("parsing gh pr list output (first 200 bytes: {})", &json.chars().take(200).collect::<String>()))?;
serde_json::from_str(&json).with_context(|| format!("parsing gh pr list output (first 200 bytes: {})", json.chars().take(200).collect::<String>()))?;
let mut out: Vec<HarvestedPR> = raw_list.into_iter().filter(is_eligible).filter_map(to_harvested).collect();
out.truncate(limit);
Ok(out)
Expand Down
11 changes: 4 additions & 7 deletions crates/smooth-bigsmooth/src/web_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,13 +322,10 @@ fn extract_attr(fragment: &str, name: &str) -> Option<String> {
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())
}
Expand Down
113 changes: 103 additions & 10 deletions crates/smooth-code/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
_ => {}
}
}

Expand All @@ -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, &current_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 {
Expand Down Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions crates/smooth-code/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/smooth-code/src/model_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
110 changes: 108 additions & 2 deletions crates/smooth-code/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListItem<'_>> = 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<chrono::Utc>) -> 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.
Expand Down Expand Up @@ -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()),
Expand All @@ -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 ")
Expand Down Expand Up @@ -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");
}
}
Loading
Loading