From 761971987e6dd59b75a650f21ee09fdb231db1f9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 4 Sep 2026 05:15:46 +0800 Subject: [PATCH] feat(tui): add /btw side question command Adds a `/btw ` command that answers side questions via a lightweight no-tools LLM call, without touching the main chat turn or session persistence. - Works on both home and chat, even while the agent is streaming - Q&A rendered in a dismissible overlay panel (Esc) with mouse-wheel scrolling and clamping - One in-flight `/btw` at a time; late replies after dismissal are dropped - Includes session history snapshot for context, with tests for session tracking, scroll behavior, and home usage --- _plans/__TODOS.md | 4 +- src/app.rs | 381 ++++++++++++++++++++++++++++++++++++++ src/command/handlers.rs | 69 ++++++- src/llm/client.rs | 145 ++++++++++++++- src/views/chat.rs | 398 ++++++++++++++++++++++++++++++++++++++-- src/views/home.rs | 22 ++- 6 files changed, 996 insertions(+), 23 deletions(-) diff --git a/_plans/__TODOS.md b/_plans/__TODOS.md index ef64b78..895103c 100644 --- a/_plans/__TODOS.md +++ b/_plans/__TODOS.md @@ -491,7 +491,7 @@ I think this is how the TUI works already anyway right? - [ ] Extra padding in non compact mode. Or idk. controllable in tui? field? Right now it's close to the edge and it only looks good in some terminals. -- [ ] /btw command +- [x] /btw command - [x] wanna add tinyfish and monid (free search apis) @@ -502,3 +502,5 @@ I think this is how the TUI works already anyway right? - [ ] multiple accounts - [x] dialog backdrop + +- [ ] the prompt history cycler has some bugs, sometimes when crabcode crashes, I press `up` and it actually rewinded to the oldest prompt i had??? diff --git a/src/app.rs b/src/app.rs index 655c155..7aa2f1a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -362,6 +362,44 @@ enum TitleGenerationTaskMessage { Generated { session_id: String, title: String }, } +/// Answer to a `/btw` side question. Kept out of `chat_state.chat.messages` +/// (and session persistence) so the aside never leaks into the main turn. +/// `session_id` is `None` on the home page. +#[derive(Debug, Clone)] +pub struct BtwEntry { + pub session_id: Option, + pub question: String, + pub answer: Option, + pub error: Option, +} + +impl BtwEntry { + pub fn pending(session_id: Option, question: String) -> Self { + Self { + session_id, + question, + answer: None, + error: None, + } + } + + pub fn is_pending(&self) -> bool { + self.answer.is_none() && self.error.is_none() + } +} + +#[derive(Debug)] +enum BtwTaskMessage { + Answered { + session_id: Option, + answer: String, + }, + Failed { + session_id: Option, + error: String, + }, +} + #[derive(Debug, Clone)] struct SmallModelConfig { provider: String, @@ -890,6 +928,12 @@ pub struct App { models_dialog_provider_ids: Option>, title_generation_receiver: Option>, + btw_receiver: Option>, + btw_entries: Vec, + /// Lines scrolled down from the top inside the `/btw` panel (0 = top). + btw_scroll: usize, + /// Last-rendered `/btw` panel rect, for mouse-wheel hit-testing. + btw_panel_area: Option, pub prefs_dao: Option, pub agent: String, pub agent_registry: crate::agent::definition::AgentRegistry, @@ -1151,6 +1195,10 @@ impl App { models_receiver: None, models_dialog_provider_ids: None, title_generation_receiver: None, + btw_receiver: None, + btw_entries: Vec::new(), + btw_scroll: 0, + btw_panel_area: None, prefs_dao, agent, agent_registry: crate::agent::definition::AgentRegistry::default(), @@ -3486,6 +3534,14 @@ impl App { } pub fn handle_coalesced_mouse_scroll(&mut self, mouse: MouseEvent, notches: usize) { + // The /btw panel scrolls independently (home and chat alike). + if matches!( + self.overlay_focus, + OverlayFocus::None | OverlayFocus::FindBar + ) && self.handle_btw_mouse_scroll(mouse, notches) + { + return; + } if matches!( self.overlay_focus, OverlayFocus::None | OverlayFocus::FindBar @@ -4433,6 +4489,11 @@ impl App { self.reset_esc_primed_state(); return true; } + // Close the /btw side panel first (works while streaming too). + if self.input.is_empty() && self.dismiss_btw_panel() { + self.reset_esc_primed_state(); + return true; + } if self.is_streaming { return self.handle_streaming_esc_key(key); } @@ -6812,6 +6873,10 @@ impl App { self.handle_fork_command(&parsed.args); return; } + if self.command_matches(&parsed.name, "btw") { + self.handle_btw_command(&parsed); + return; + } if self.reject_chat_only_command_outside_chat(&parsed.name) { return; } @@ -7056,6 +7121,10 @@ impl App { self.handle_fork_command(&parsed.args); return; } + if self.command_matches(&parsed.name, "btw") { + self.handle_btw_command(&parsed); + return; + } if self.reject_chat_only_command_outside_chat(&parsed.name) { return; } @@ -9407,6 +9476,189 @@ impl App { } } + /// Fire a `/btw` side question: lightweight no-tools call that bypasses the + /// main streaming turn. Works on home and chat, and while the agent is busy; + /// the Q&A stays out of `chat_state.chat.messages` so it never leaks into + /// the main turn. + fn handle_btw_command(&mut self, parsed: &crate::command::parser::ParsedCommand) { + let question = parsed.raw_args().trim().to_string(); + if question.is_empty() { + self.push_command_error("Usage: /btw "); + return; + } + // No active session on the home page — key the entry to `None` there. + let session_id = self.session_manager.get_current_session_id().cloned(); + if self.btw_receiver.is_some() { + push_toast(Toast::new( + "Already answering a /btw question...", + ToastLevel::Info, + Some(std::time::Duration::from_secs(2)), + )); + return; + } + + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + self.btw_receiver = Some(receiver); + self.btw_entries + .push(BtwEntry::pending(session_id.clone(), question.clone())); + // Pin to the top of the fresh panel. + self.btw_scroll = 0; + self.note_user_activity(); + + let provider = self.provider_name.clone(); + let model = self.model.clone(); + // Snapshot history for context (Grok resolves history server-side via + // session_id; we attach the optimized slice client-side). + let history: Vec = session_id + .as_deref() + .and_then(|id| self.chat_for_session(id)) + .map(|chat| chat.messages.clone()) + .unwrap_or_default(); + tokio::spawn(async move { + let message = + match crate::llm::client::generate_btw_answer(provider, model, question, history) + .await + { + Ok(answer) => BtwTaskMessage::Answered { session_id, answer }, + Err(err) => BtwTaskMessage::Failed { + session_id, + error: err.to_string(), + }, + }; + let _ = sender.send(message); + }); + } + + /// Latest `/btw` entry for the current context (home or active session). + pub fn current_btw_entry(&self) -> Option<&BtwEntry> { + let session_id = self.session_manager.get_current_session_id().cloned(); + self.btw_entries + .iter() + .rev() + .find(|entry| entry.session_id == session_id) + } + + /// Close the `/btw` panel for the current context (Esc). A late reply to + /// an already-closed panel is dropped. + pub fn dismiss_btw_panel(&mut self) -> bool { + let session_id = self.session_manager.get_current_session_id().cloned(); + let before = self.btw_entries.len(); + self.btw_entries + .retain(|entry| entry.session_id != session_id); + if before == self.btw_entries.len() { + return false; + } + // Drop the in-flight receiver too so a late reply is discarded and a + // fresh /btw can start immediately. + self.btw_receiver = None; + self.btw_scroll = 0; + true + } + + /// Max scroll offset (in body lines) for the current `/btw` panel. + /// Derived from the panel's actual rendered area (not a width guess) so + /// the wrap and the viewport always match, even on small terminals. + fn btw_scroll_max(&self, colors: &crate::theme::ThemeColors) -> usize { + let Some(area) = self.btw_panel_area else { + return 0; + }; + let Some(entry) = self.current_btw_entry() else { + return 0; + }; + let total = crate::views::chat::btw_body_lines( + entry, + crate::views::chat::btw_body_width(area.width), + colors, + ) + .len() + .max(1); + total.saturating_sub(crate::views::chat::btw_body_viewport(area.height)) + } + + /// Mouse-wheel scroll inside the `/btw` panel. Returns true when consumed. + fn handle_btw_mouse_scroll(&mut self, mouse: MouseEvent, notches: usize) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + return false; + } + if self.current_btw_entry().is_none() { + return false; + } + let Some(area) = self.btw_panel_area else { + return false; + }; + if !area.contains(Position::new(mouse.column, mouse.row)) { + return false; + } + let colors = self.get_current_theme_colors(); + let max = self.btw_scroll_max(&colors); + let step = notches.max(1) * 3; + match mouse.kind { + MouseEventKind::ScrollUp => { + self.btw_scroll = self.btw_scroll.saturating_sub(step).min(max); + } + _ => { + self.btw_scroll = self.btw_scroll.saturating_add(step).min(max); + } + } + true + } + + fn process_btw_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + if let Some(receiver) = &mut self.btw_receiver { + while let Ok(event) = receiver.try_recv() { + events.push(event); + } + } else { + return; + } + if let Some(receiver) = &self.btw_receiver { + disconnected = receiver.is_closed() && receiver.is_empty(); + } + + if disconnected { + self.btw_receiver = None; + } + + for event in events { + match event { + BtwTaskMessage::Answered { session_id, answer } => { + if let Some(entry) = self + .btw_entries + .iter_mut() + .rev() + .find(|entry| entry.session_id == session_id && entry.is_pending()) + { + entry.answer = Some(answer); + } + self.btw_receiver = None; + self.play_sound_event(crate::sound::SoundEvent::Complete); + } + BtwTaskMessage::Failed { session_id, error } => { + if let Some(entry) = self + .btw_entries + .iter_mut() + .rev() + .find(|entry| entry.session_id == session_id && entry.is_pending()) + { + entry.error = Some(error.clone()); + } + self.btw_receiver = None; + self.play_sound_event(crate::sound::SoundEvent::Error); + push_toast(Toast::new( + format!("btw failed: {}", error), + ToastLevel::Error, + Some(std::time::Duration::from_secs(4)), + )); + } + } + } + } + fn cleanup_streaming(&mut self) { if let Some(session_id) = self.session_manager.get_current_session_id().cloned() { self.cleanup_streaming_for_session(&session_id); @@ -9685,6 +9937,7 @@ impl App { self.process_storage_events(); self.process_models_events(); self.process_title_generation_events(); + self.process_btw_events(); let drained = { let mut receivers = Vec::new(); @@ -11645,6 +11898,8 @@ impl App { match self.base_focus { BaseFocus::Home => { + // Clone: render_home takes &mut self.input below. + let btw_entry = self.current_btw_entry().cloned(); render_home( f, &mut self.input, @@ -11660,6 +11915,9 @@ impl App { mcp_summary, &colors, usage_text, + btw_entry.as_ref(), + self.btw_scroll, + &mut self.btw_panel_area, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -11705,6 +11963,9 @@ impl App { let is_streaming = self.is_streaming; let is_compacting = self.compaction_receiver.is_some(); let esc_cancel_primed = is_streaming && self.esc_is_primed(); + // Clone: render_chat takes &mut self fields below, and the panel + // must never alias the main chat messages anyway. + let btw_entry = self.current_btw_entry().cloned(); render_chat( f, &mut self.chat_state, @@ -11726,6 +11987,9 @@ impl App { usage_text, subagent_tabs, &queued_messages, + btw_entry.as_ref(), + self.btw_scroll, + &mut self.btw_panel_area, &mut self.find_bar, self.overlay_focus == OverlayFocus::None, self.session_manager @@ -12412,6 +12676,10 @@ mod tests { models_receiver: None, models_dialog_provider_ids: None, title_generation_receiver: None, + btw_receiver: None, + btw_entries: Vec::new(), + btw_scroll: 0, + btw_panel_area: None, prefs_dao: None, agent: "Build".to_string(), agent_registry: crate::agent::definition::AgentRegistry::default(), @@ -15664,6 +15932,119 @@ mod tests { ); } + #[test] + fn btw_panel_tracks_current_session_and_closes() { + // Isolate XDG_STATE_HOME: create_new_session persists via HistoryDAO. + let _state = crate::jobs::test_env::TempState::new(); + let mut app = test_app(); + app.create_new_session(Some("btw session".to_string())); + assert!(app.current_btw_entry().is_none()); + + let session_id = app + .session_manager + .get_current_session_id() + .cloned() + .expect("session"); + app.btw_entries.push(BtwEntry::pending( + Some(session_id.clone()), + "also check error handling".to_string(), + )); + assert_eq!( + app.current_btw_entry().map(|entry| entry.question.as_str()), + Some("also check error handling") + ); + + // Late reply fills the pending entry without touching main chat. + let chat_len = app.chat_state.chat.messages.len(); + app.btw_receiver = None; + app.btw_entries + .iter_mut() + .rev() + .find(|entry| entry.session_id == Some(session_id.clone()) && entry.is_pending()) + .expect("pending btw") + .answer = Some("looks fine".to_string()); + assert_eq!(app.chat_state.chat.messages.len(), chat_len); + + assert!(app.dismiss_btw_panel()); + assert!(app.current_btw_entry().is_none()); + assert!(!app.dismiss_btw_panel()); + } + + #[test] + fn btw_panel_scrolls_with_mouse_wheel_and_clamps() { + let mut app = test_app(); + let mut entry = BtwEntry::pending(None, "long answer".to_string()); + entry.answer = Some( + (1..=30) + .map(|n| format!("para {n}")) + .collect::>() + .join("\n\n"), + ); + app.btw_entries.push(entry); + // Pretend the panel was rendered above the input at full height. + app.btw_panel_area = Some(ratatui::layout::Rect::new(0, 10, 80, 13)); + + let colors = app.get_current_theme_colors(); + let max = app.btw_scroll_max(&colors); + assert!(max > 0); + + assert!(app.handle_btw_mouse_scroll(mouse(MouseEventKind::ScrollDown, 40, 12), 1)); + assert_eq!(app.btw_scroll, 3.min(max)); + // Outside the panel → not consumed, offset untouched. + assert!(!app.handle_btw_mouse_scroll(mouse(MouseEventKind::ScrollDown, 40, 2), 1)); + assert_eq!(app.btw_scroll, 3.min(max)); + // Scrolling far down clamps at max. + assert!(app.handle_btw_mouse_scroll(mouse(MouseEventKind::ScrollDown, 40, 12), 100)); + assert_eq!(app.btw_scroll, max); + // Scroll back up clamps at the top. + assert!(app.handle_btw_mouse_scroll(mouse(MouseEventKind::ScrollUp, 40, 12), 100)); + assert_eq!(app.btw_scroll, 0); + } + + #[test] + fn btw_scroll_clamp_matches_short_panel_render() { + let mut app = test_app(); + let mut entry = BtwEntry::pending(None, "long answer".to_string()); + entry.answer = Some( + (1..=17) + .map(|n| format!("para {n}")) + .collect::>() + .join("\n\n"), + ); + app.btw_entries.push(entry); + // Shrunken panel: only 5 body lines actually visible. + app.btw_panel_area = Some(ratatui::layout::Rect::new(0, 18, 80, 8)); + + let colors = app.get_current_theme_colors(); + let total = crate::views::chat::btw_body_lines( + app.current_btw_entry().expect("entry"), + crate::views::chat::btw_body_width(80), + &colors, + ) + .len(); + // 17 paragraphs render with blank separators: 17 + 16 = 33 lines. + assert_eq!(total, 33); + // 33 - 5 visible = 28, so the last line is always reachable. + assert_eq!(app.btw_scroll_max(&colors), 28); + } + + #[test] + fn btw_panel_works_on_home_without_session() { + let mut app = test_app(); + assert!(app.session_manager.get_current_session_id().is_none()); + assert!(app.current_btw_entry().is_none()); + + app.btw_entries + .push(BtwEntry::pending(None, "what is crabcode?".to_string())); + assert_eq!( + app.current_btw_entry().map(|entry| entry.question.as_str()), + Some("what is crabcode?") + ); + + assert!(app.dismiss_btw_panel()); + assert!(app.current_btw_entry().is_none()); + } + #[test] fn ctrl_n_is_not_a_global_new_session_shortcut() { let mut app = test_app(); diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 8ec79c0..07e8ead 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -666,6 +666,23 @@ pub fn handle_compact_mode<'a>( }) } +pub fn handle_btw<'a>( + parsed: &'a ParsedCommand, + _sm: &'a mut SessionManager, +) -> Pin + Send + 'a>> { + let question = parsed.raw_args().to_string(); + + Box::pin(async move { + if question.trim().is_empty() { + return CommandResult::Error("Usage: /btw ".to_string()); + } + + // The app intercepts /btw because it needs the active provider/model + // and must run outside the main streaming turn. + CommandResult::Success(String::new()) + }) +} + pub fn handle_fork<'a>( parsed: &'a ParsedCommand, _sm: &'a mut SessionManager, @@ -1046,6 +1063,14 @@ pub fn register_all_commands(registry: &mut Registry) { chat_only: true, }); + registry.register(Command { + name: "btw".to_string(), + description: "Ask a side question without interrupting the current task".to_string(), + handler: handle_btw, + hidden_tokens: vec![], + chat_only: false, + }); + registry.register(Command { name: "fork".to_string(), description: "Fork the current session".to_string(), @@ -1098,6 +1123,46 @@ mod tests { registry } + #[tokio::test] + async fn test_handle_btw_requires_question() { + let parsed = ParsedCommand { + name: "btw".to_string(), + args: vec![], + raw: "/btw".to_string(), + prefs_data: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = handle_btw(&parsed, &mut session_manager).await; + assert_eq!( + result, + CommandResult::Error("Usage: /btw ".to_string()) + ); + } + + #[tokio::test] + async fn test_handle_btw_with_question_defers_to_app() { + let parsed = ParsedCommand { + name: "btw".to_string(), + args: vec!["also".to_string(), "check".to_string()], + raw: "/btw also check".to_string(), + prefs_data: None, + active_model_id: None, + }; + let mut session_manager = SessionManager::new(); + let result = handle_btw(&parsed, &mut session_manager).await; + // The app intercepts /btw (needs provider/model + side-channel turn). + assert_eq!(result, CommandResult::Success(String::new())); + } + + #[test] + fn test_btw_registered_for_home_and_chat() { + let registry = create_registry(); + let command = registry.get("btw").expect("/btw registered"); + assert!(!registry.is_chat_only("btw")); + assert!(!command.description.is_empty()); + } + #[tokio::test] async fn test_handle_exit() { let parsed = ParsedCommand { @@ -1433,7 +1498,8 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 22); + assert_eq!(names.len(), 23); + assert!(names.contains(&"btw".to_string())); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); @@ -1456,6 +1522,7 @@ mod tests { assert!(names.contains(&"status".to_string())); assert!(registry.is_chat_only("compact")); assert!(registry.is_chat_only("fork")); + assert!(!registry.is_chat_only("btw")); assert!(registry.is_chat_only("move")); assert!(registry.is_chat_only("branch")); assert_eq!(registry.get("branch").unwrap().name, "fork"); diff --git a/src/llm/client.rs b/src/llm/client.rs index 350d36a..8dddaec 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -1069,6 +1069,130 @@ pub async fn generate_session_title( Ok(title) } +/// Max history tokens attached to a `/btw` side question. Keeps the aside +/// cheap: newest turns plus the compaction summary (compressed history). +const BTW_HISTORY_MAX_TOKENS: usize = 8_000; + +/// Build `/btw` context from session history with the usual harness +/// optimizations: compaction boundary (post-summary slice only), drop +/// in-flight partials, newest-first token budget. The leading compaction +/// summary is always preserved when present since it *is* the compressed +/// history. +fn btw_context_messages( + history: &[crate::session::types::Message], +) -> Vec { + let complete: Vec = history + .iter() + .filter(|message| message.is_complete) + .cloned() + .collect(); + let context = crate::session::compaction::filter_messages_for_context(&complete); + let (summary, rest) = match context.first() { + Some(first) if crate::session::compaction::is_compaction_summary(first) => { + (Some(first.clone()), &context[1..]) + } + _ => (None, &context[..]), + }; + let summary_tokens = summary + .as_ref() + .map(crate::session::compaction::message_context_tokens) + .unwrap_or(0); + let mut budget = BTW_HISTORY_MAX_TOKENS.saturating_sub(summary_tokens); + let mut kept: Vec = Vec::new(); + for message in rest.iter().rev() { + let tokens = crate::session::compaction::message_context_tokens(message); + if tokens > budget && !kept.is_empty() { + break; + } + budget = budget.saturating_sub(tokens.min(budget)); + kept.push(message.clone()); + } + kept.reverse(); + match summary { + Some(summary) => std::iter::once(summary).chain(kept).collect(), + None => kept, + } +} + +/// Lightweight side answer for `/btw`: no tools, but with session history. +/// +/// Like [`generate_session_title`], this bypasses the main streaming turn so it +/// can run while the agent is busy. The question and answer are never added to +/// the main turn — callers must keep them out of `chat_state.chat.messages`. +pub async fn generate_btw_answer( + provider_name: String, + model: String, + question: String, + history: Vec, +) -> Result { + let (warning_sender, _warning_receiver) = tokio::sync::mpsc::unbounded_channel(); + let request_config = + prepare_request_config(&provider_name, model, None, &warning_sender).await?; + let context = btw_context_messages(&history); + crate::emit_log!( + "BTW history_messages={} context_messages={} question_chars={}", + history.len(), + context.len(), + question.trim().len() + ); + let mut messages = + convert_messages_for_model(&context, request_config.supports_image_input, false); + messages.push(AisdkMessage::system( + "You are answering a quick side question about the ongoing conversation. Be concise. No tools are available; answer from the conversation context and general knowledge.", + )); + if context.is_empty() { + messages.push(AisdkMessage::user(format!( + "Side question:\n{}", + question.trim() + ))); + } else { + messages.push(AisdkMessage::user(format!( + "Side question about the conversation above:\n{}", + question.trim() + ))); + } + let mut response = + stream_provider_request(&request_config, messages, Vec::new(), None, None).await?; + + let mut answer = String::new(); + while let Some(chunk) = response.stream.next().await { + match chunk { + ChunkType::Text(text) => answer.push_str(&text), + ChunkType::Failed(err) => { + return Err(anyhow::anyhow!("btw request failed: {}", err).into()); + } + ChunkType::NotSupported(msg) => { + return Err(anyhow::anyhow!("btw request unsupported: {}", msg).into()); + } + ChunkType::Reasoning(_) + | ChunkType::ReasoningItem(_) + | ChunkType::ToolCall(_) + | ChunkType::ProviderToolCall(_) + | ChunkType::End { .. } + | ChunkType::AssistantMessagePhase { .. } + | ChunkType::ResponseCompleted { .. } + | ChunkType::Retry(_) + | ChunkType::RetryableFailure(_) + | ChunkType::Warning(_) + | ChunkType::Metadata(_) + | ChunkType::Usage(_) + | ChunkType::Start + | ChunkType::Incomplete(_) => {} + ChunkType::StreamRollback { text, .. } => { + if answer.ends_with(&text) { + answer.truncate(answer.len() - text.len()); + } + } + } + } + + let answer = answer.trim().to_string(); + if answer.is_empty() { + return Err(anyhow::anyhow!("btw request returned an empty answer").into()); + } + Ok(answer) +} + fn sanitize_generated_title(raw: &str) -> String { let mut title = raw .trim() @@ -2623,8 +2747,8 @@ fn normalize_anthropic_base_url(base_url: &str) -> String { #[cfg(test)] mod tests { use super::{ - apply_compaction_stream_chunk, apply_provider_request_defaults, convert_messages, - convert_messages_for_model, is_openai_oauth_model_allowed, + apply_compaction_stream_chunk, apply_provider_request_defaults, btw_context_messages, + convert_messages, convert_messages_for_model, is_openai_oauth_model_allowed, maybe_apply_unauthenticated_free_provider_key, model_supports_image_input, openai_oauth_default_originator, openai_oauth_model_uses_responses_lite, openai_request_instructions, resolve_api_key, resolve_model_route, @@ -2635,6 +2759,23 @@ mod tests { use crate::persistence::AuthConfig; + #[test] + fn btw_context_drops_incomplete_and_keeps_newest() { + use crate::session::types::Message; + let history = vec![ + Message::user("old question"), + Message::assistant("old answer"), + Message::incomplete("partial streaming..."), + ]; + let context = btw_context_messages(&history); + assert_eq!(context.len(), 2); + assert!(context.iter().all(|message| message.is_complete)); + assert_eq!( + context.last().map(|m| m.content.as_str()), + Some("old answer") + ); + } + #[test] fn compaction_stream_accumulates_usage_and_text() { let mut summary = String::new(); diff --git a/src/views/chat.rs b/src/views/chat.rs index ec36ef6..e163683 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -16,6 +16,7 @@ use crate::ui::components::find::FindBar; use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; use crate::ui::components::wave_spinner::WaveSpinner; +use crate::ui::markdown::streaming::render_markdown; use crate::ui::selection::non_selectable_style; pub const SUBAGENT_FOOTER_HEIGHT: u16 = 3; @@ -155,6 +156,9 @@ pub fn render_chat( usage_text: &str, subagent_tabs: Option, queued_messages: &[String], + btw_entry: Option<&crate::app::BtwEntry>, + btw_scroll: usize, + btw_panel_area: &mut Option, find_bar: &mut FindBar, show_terminal_cursor: bool, session_title: Option<&str>, @@ -183,6 +187,11 @@ pub fn render_chat( } else { queued_messages_height(queued_messages) }; + let btw_height = if is_subagent_view { + 0 + } else { + btw_panel_height(btw_entry, size.width, colors) + }; let above_status_chunks = Layout::default() .direction(Direction::Vertical) .constraints( @@ -190,7 +199,7 @@ pub fn render_chat( Constraint::Length(0), // Reserved subagent header removed Constraint::Min(0), // Chat content Constraint::Length(0), // Bottom padding - Constraint::Length(queue_height), + Constraint::Length(queue_height + btw_height), Constraint::Length(input_height), Constraint::Length(help_height), Constraint::Length(1), @@ -452,9 +461,27 @@ pub fn render_chat( ); } } else { + let above_input = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(btw_height), + Constraint::Length(queue_height), + ] + .as_ref(), + ) + .split(above_status_chunks[3]); + render_btw_panel( + f, + above_input[0], + btw_entry, + btw_scroll, + btw_panel_area, + colors, + ); render_queued_messages( f, - above_status_chunks[3], + above_input[1], queued_messages, &agent, colors, @@ -1085,14 +1112,14 @@ fn render_queued_messages( } let mut lines = Vec::new(); - let hint = if esc_cancel_primed { - "esc again to interrupt and send immediately" + let hint_prefix = if esc_cancel_primed { + "esc again to " } else { - "esc interrupt and send immediately" + "esc " }; - let title = "Messages to submit after next tool call"; + let hint_width = UnicodeWidthStr::width(hint_prefix) + UnicodeWidthStr::width("steer"); + let title = "Queued message"; let title_width = 2 + UnicodeWidthStr::width(title); - let hint_width = UnicodeWidthStr::width(hint); let show_hint = content_area.width as usize >= title_width + hint_width + 4; let mut header_spans = vec![ @@ -1111,8 +1138,16 @@ fn render_queued_messages( .saturating_sub((title_width + hint_width) as u16); header_spans.push(Span::raw(" ".repeat(spacer_width as usize))); header_spans.push(Span::styled( - hint, - cancel_hint_style(colors, esc_cancel_primed), + hint_prefix, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + header_spans.push(Span::styled( + "steer", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), )); } lines.push(Line::from(header_spans)); @@ -1200,6 +1235,263 @@ fn truncate_to_width(value: &str, max_width: usize) -> String { rendered } +pub(crate) const BTW_MAX_BODY_LINES: usize = 10; +const BTW_TOP_PADDING: u16 = 1; +const BTW_BOTTOM_PADDING: u16 = 1; +/// Lines scrolled per mouse-wheel notch inside the `/btw` panel. +const BTW_SCROLL_STEP: usize = 3; + +/// Text width available for the `/btw` answer body: panel width minus the +/// left border (1), content inset (3) and the 2-space body indent. +/// +/// Keep in sync with the layout in [`render_btw_panel`]: the content area +/// there is `inner(2 + 1 wide)`, and the body is laid out at +/// `content_area.width - 2`. +pub(crate) fn btw_body_width(panel_width: u16) -> usize { + content_width_for_area_width(panel_width) +} + +fn content_width_for_area_width(panel_width: u16) -> usize { + (panel_width as usize).saturating_sub(1 + 3 + 2).max(10) +} + +/// Body viewport (visible answer lines) for a panel of the given height. +/// Shared by the mouse-scroll clamp ([`App::btw_scroll_max`]) and render so +/// the scroll window always matches the space that was actually allocated — +/// critical when the terminal is too short for the full 10-line cap. +pub(crate) fn btw_body_viewport(panel_height: u16) -> usize { + (panel_height as usize) + .saturating_sub((BTW_TOP_PADDING + BTW_BOTTOM_PADDING + 1) as usize) + .max(1) +} + +/// Rendered `/btw` answer body: full markdown like the main transcript. +/// Pending and error states stay plain styled text. +pub(crate) fn btw_body_lines( + entry: &crate::app::BtwEntry, + content_width: usize, + colors: &ThemeColors, +) -> Vec> { + if let Some(answer) = entry.answer.as_deref() { + let mut lines = render_markdown(answer, content_width.max(10), colors); + if lines.is_empty() { + lines.push(Line::from("")); + } + return lines; + } + if let Some(error) = entry.error.as_deref() { + let style = Style::default().fg(colors.error); + let mut lines = Vec::new(); + for source_line in format!("error: {error}").lines() { + for wrapped in wrap_plain_text_line(source_line, content_width.max(10)) { + lines.push(Line::styled(wrapped, style)); + } + } + if lines.is_empty() { + lines.push(Line::from("")); + } + return lines; + } + vec![Line::styled( + "thinking…", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )] +} + +/// Height of the `/btw` side-answer panel above the input (0 when absent). +/// Capped at header + [`BTW_MAX_BODY_LINES`] + padding; longer answers +/// scroll inside the panel instead of growing it. +pub(crate) fn btw_panel_height( + entry: Option<&crate::app::BtwEntry>, + width: u16, + colors: &ThemeColors, +) -> u16 { + let Some(entry) = entry else { + return 0; + }; + if width == 0 { + return 0; + } + let total = btw_body_lines(entry, btw_body_width(width), colors) + .len() + .max(1); + let visible = total.min(BTW_MAX_BODY_LINES); + BTW_TOP_PADDING + (1 + visible) as u16 + BTW_BOTTOM_PADDING +} + +fn wrap_plain_text_line(line: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + if line.is_empty() { + return vec![String::new()]; + } + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + for word in line.split_whitespace() { + let word_width = UnicodeWidthStr::width(word); + let separator_width = usize::from(!current.is_empty()); + if !current.is_empty() && current_width + separator_width + word_width <= width { + current.push(' '); + current.push_str(word); + current_width += separator_width + word_width; + continue; + } + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + if word_width <= width { + current.push_str(word); + current_width = word_width; + } else { + // Split overlong words on char boundaries. + let mut chunk = String::new(); + let mut chunk_width = 0usize; + for ch in word.chars() { + let char_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if chunk_width + char_width > width && !chunk.is_empty() { + lines.push(std::mem::take(&mut chunk)); + chunk_width = 0; + } + chunk.push(ch); + chunk_width += char_width; + } + current = chunk; + current_width = chunk_width; + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +pub(crate) fn render_btw_panel( + f: &mut Frame, + area: Rect, + entry: Option<&crate::app::BtwEntry>, + scroll_offset: usize, + panel_area_out: &mut Option, + colors: &ThemeColors, +) { + let Some(entry) = entry else { + return; + }; + if area.width == 0 || area.height == 0 { + return; + } + // Hit-test target for mouse-wheel scrolling over the panel. + *panel_area_out = Some(area); + + let border_set = border::Set { + vertical_left: "┃", + ..border::PLAIN + }; + let border = Block::new() + .borders(Borders::LEFT) + .border_set(border_set) + .border_style(Style::default().fg(colors.info)); + let inner_area = border.inner(area); + let panel_bg = queued_messages_background(colors); + let bg = Block::default().style(Style::default().bg(panel_bg)); + f.render_widget(bg, area); + f.render_widget(border, area); + + let content_area = Rect { + x: inner_area.x.saturating_add(2), + y: inner_area.y.saturating_add(BTW_TOP_PADDING), + width: inner_area.width.saturating_sub(3), + height: inner_area + .height + .saturating_sub(BTW_TOP_PADDING + BTW_BOTTOM_PADDING), + }; + if content_area.width == 0 || content_area.height == 0 { + return; + } + + let mut lines = Vec::new(); + // The panel is always laid out at the width `btw_panel_height` computed + // for, and its viewport is the actual allocated body height — never the + // 10-line cap. Both must match render or the wrap (total) and the max + // offset drift apart on small terminals. + let viewport = btw_body_viewport(area.height); + let content_width = content_width_for_area_width(area.width); + let body_all = btw_body_lines(entry, content_width, colors); + let total = body_all.len().max(1); + let max_offset = total.saturating_sub(viewport); + let start = scroll_offset.min(max_offset); + let end = (start + viewport).min(total); + + let mut hint_text = String::from("esc dismiss"); + if max_offset > 0 { + hint_text.push_str(&format!(" · ↑↓ {end}/{total}")); + } + let hint_width = UnicodeWidthStr::width(hint_text.as_str()); + let title = format!("◐ btw — {}", entry.question); + let title = truncate_to_width(&title, content_area.width as usize); + let title_width = 2 + UnicodeWidthStr::width(title.as_str()); + let show_hint = content_area.width as usize >= title_width + hint_width + 4; + + let mut header_spans = vec![ + Span::styled("•", Style::default().fg(colors.info)), + Span::raw(" "), + Span::styled( + title, + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + ), + ]; + if show_hint { + let spacer_width = content_area + .width + .saturating_sub((title_width + hint_width) as u16); + header_spans.push(Span::raw(" ".repeat(spacer_width as usize))); + header_spans.push(Span::styled( + "esc ", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + header_spans.push(Span::styled( + "dismiss", + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::BOLD), + )); + if max_offset > 0 { + header_spans.push(Span::styled( + format!(" · ↑↓ {end}/{total}"), + Style::default() + .fg(colors.text_weak) + .add_modifier(Modifier::DIM), + )); + } + } + lines.push(Line::from(header_spans)); + + for mut body_line in body_all.into_iter().skip(start).take(viewport) { + // Keep the 2-space body indent; preserve each line's own style. + let mut spans = Vec::with_capacity(body_line.spans.len() + 1); + spans.push(Span::raw(" ")); + spans.append(&mut body_line.spans); + let mut line = Line::from(spans); + line.style = body_line.style; + lines.push(line); + } + + f.render_widget( + Paragraph::new(Text::from(lines)).style(Style::default().bg(panel_bg)), + content_area, + ); +} + fn render_subagent_footer( f: &mut Frame, area: ratatui::layout::Rect, @@ -1401,12 +1693,13 @@ fn centered_subagent_footer_content(area: Rect) -> Rect { #[cfg(test)] mod tests { use super::{ - chat_status_layout_widths, compact_transcript_layout, display_agent_name, - natural_sticky_index, paint_sticky_overlay, render_chat, render_subagent_spinner_only, - resolve_sticky_display, sticky_overlay_height_for_span, sticky_overlay_rect, - streaming_status_spans, subagent_nav_width, subagent_streaming_status_spans, - user_message_body_end, ChatState, ChatStatusLayoutWidths, STICKY_UP_HYSTERESIS, - STREAMING_STATUS_COMPACT_BREAKPOINT_WIDTH, + btw_body_lines, btw_body_width, btw_panel_height, chat_status_layout_widths, + compact_transcript_layout, display_agent_name, natural_sticky_index, paint_sticky_overlay, + render_chat, render_subagent_spinner_only, resolve_sticky_display, + sticky_overlay_height_for_span, sticky_overlay_rect, streaming_status_spans, + subagent_nav_width, subagent_streaming_status_spans, user_message_body_end, + wrap_plain_text_line, ChatState, ChatStatusLayoutWidths, BTW_MAX_BODY_LINES, + STICKY_UP_HYSTERESIS, STREAMING_STATUS_COMPACT_BREAKPOINT_WIDTH, }; use crate::theme::ThemeColors; use crate::ui::components::{ @@ -2148,6 +2441,9 @@ mod tests { "", None, &[], + None, + 0, + &mut None, &mut find_bar, true, Some("Session"), @@ -2207,6 +2503,9 @@ mod tests { "", None, &[], + None, + 0, + &mut None, &mut find_bar, true, Some("Session"), @@ -2286,6 +2585,9 @@ mod tests { "", None, &[], + None, + 0, + &mut None, &mut find_bar, true, Some("Session"), @@ -2305,4 +2607,70 @@ mod tests { // Overlay helpers agree: no sticky height → no overlay rect. assert!(sticky_overlay_rect(chat_area, 0).is_none()); } + + #[test] + fn btw_panel_height_is_zero_without_entry() { + assert_eq!(btw_panel_height(None, 80, &test_colors()), 0); + } + + #[test] + fn btw_panel_height_grows_with_wrapped_answer() { + let colors = test_colors(); + let pending = + crate::app::BtwEntry::pending(Some("s1".to_string()), "what broke?".to_string()); + assert!(pending.is_pending()); + let pending_height = btw_panel_height(Some(&pending), 80, &colors); + // Header + 1 body line + padding. + assert_eq!(pending_height, 1 + (1 + 1) + 1); + + let mut answered = + crate::app::BtwEntry::pending(Some("s1".to_string()), "what broke?".to_string()); + answered.answer = Some("one two three four five".to_string()); + // Narrow width forces the answer onto 3 lines. + assert_eq!( + btw_panel_height(Some(&answered), 18, &colors), + 1 + (1 + 3) + 1 + ); + } + + #[test] + fn btw_panel_height_caps_long_answers() { + let colors = test_colors(); + let mut answered = + crate::app::BtwEntry::pending(Some("s1".to_string()), "long answer".to_string()); + answered.answer = Some( + (1..=30) + .map(|n| format!("line {n}")) + .collect::>() + .join("\n\n"), + ); + // Header + capped viewport + padding, regardless of total lines. + assert_eq!( + btw_panel_height(Some(&answered), 80, &colors), + 1 + (1 + BTW_MAX_BODY_LINES) as u16 + 1 + ); + let lines = btw_body_lines(&answered, btw_body_width(80), &colors); + assert!(lines.len() > BTW_MAX_BODY_LINES); + } + + #[test] + fn btw_body_renders_markdown_formatting() { + let colors = test_colors(); + let mut answered = + crate::app::BtwEntry::pending(Some("s1".to_string()), "format me".to_string()); + answered.answer = Some("Hello **bold** and `code`".to_string()); + let lines = btw_body_lines(&answered, btw_body_width(80), &colors); + assert_eq!(lines.len(), 1); + // Bold + code produce multiple styled spans, not one plain span. + assert!(lines[0].spans.len() > 1); + let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(text.contains("bold") && text.contains("code")); + } + + #[test] + fn wrap_plain_text_line_splits_long_words() { + let lines = wrap_plain_text_line("abcdefghij", 4); + assert_eq!(lines, vec!["abcd", "efgh", "ij"]); + assert_eq!(wrap_plain_text_line("", 10), vec![String::new()]); + } } diff --git a/src/views/home.rs b/src/views/home.rs index 794cef0..3f7fe58 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -11,6 +11,7 @@ use unicode_width::UnicodeWidthStr; use crate::theme::ThemeColors; use crate::ui::components::input::Input; use crate::ui::components::status_bar::StatusBar; +use crate::views::chat::{btw_panel_height, render_btw_panel}; const LOGO: &str = include_str!("../../crabcode-logo.txt"); const MASCOT: &str = include_str!("../../mascot.txt"); @@ -90,6 +91,9 @@ pub fn render_home( mcp_summary: McpSummary, colors: &ThemeColors, usage_text: &str, + btw_entry: Option<&crate::app::BtwEntry>, + btw_scroll: usize, + btw_panel_area: &mut Option, ) { let size = f.area(); @@ -98,12 +102,14 @@ pub fn render_home( .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(size); + let btw_height = btw_panel_height(btw_entry, size.width, colors); let input_height = input.get_height_for_width(size.width); let home_chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Min(0), + Constraint::Length(btw_height), Constraint::Length(input_height), Constraint::Length(1), Constraint::Length(1), @@ -232,7 +238,7 @@ pub fn render_home( } input.render( f, - home_chunks[1], + home_chunks[2], &agent, &model, &provider_name, @@ -241,6 +247,14 @@ pub fn render_home( colors, true, ); + render_btw_panel( + f, + home_chunks[1], + btw_entry, + btw_scroll, + btw_panel_area, + colors, + ); let help_text = vec![ Span::styled("tab", Style::default().fg(colors.info)), @@ -250,7 +264,7 @@ pub fn render_home( ]; let help_line = Line::from(help_text); let help_width = help_line.width() as u16; - let available_width = home_chunks[2].width; + let available_width = home_chunks[3].width; let help_width = help_width.min(available_width); let mut status_spans = Vec::new(); @@ -286,7 +300,7 @@ pub fn render_home( Constraint::Min(0), Constraint::Length(help_width), ]) - .split(home_chunks[2]); + .split(home_chunks[3]); if status_width > 0 { f.render_widget(Paragraph::new(status_line), status_chunks[0]); @@ -298,7 +312,7 @@ pub fn render_home( // Keep spacer on theme canvas (don't Reset over solid bg). f.render_widget( Block::default().style(Style::default().bg(colors.background)), - home_chunks[3], + home_chunks[4], ); let status_bar = StatusBar::new(version, cwd, branch, agent, model);