use super::shared;
use crate::{
core::{
extensions::{
DockSpan, Extension, ExtensionCommand, ExtensionHook, ExtensionMode, ExtensionTool,
ExtensionUiRequest, OverlayEvent, OverlayInput, OverlayKey, OverlaySize, OverlayView,
next_ui_id, request_ui,
},
provider::AgentMessage,
session_manager::Session,
},
extensions::{
ACCENT, ERROR, EXTENSION_FACTORIES, ExtensionFactory, MUTED, PRIORITY_BTW, command_arg,
util::{session_id_of, spinner_frame},
},
modes::interactive::{
app::{App, MsgLevel},
handlers::register_slash_command,
},
utils::{display::wrap_text, glyphs::DEF_FAILED},
};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
const EXT: &str = "btw";
const SYSTEM: &str = "You answer a quick side question about the coding session shown below. You are read-only: you \
cannot run commands, edit files, or continue the task. Answer from the excerpt; if it does not \
contain the answer, say so briefly. Keep it short and concrete.";
const EXCERPT_MESSAGES: usize = 4;
const EXCERPT_CHARS: usize = 4000;
const MAX_TOKENS: u32 = 500;
const MAX_ROWS: u16 = 16;
const PROMPT_HISTORY_TURNS: usize = 6;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static BTW_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_BTW,
make: || -> Arc<dyn Extension> { Arc::new(Btw) },
};
#[derive(Default)]
struct State {
overlay_id: Option<u64>,
question: String,
answer: Option<String>,
error: Option<String>,
refresh_pending: bool,
provider: Option<String>,
model_id: Option<String>,
excerpt: String,
history: Vec<(String, String)>,
request_gen: u64,
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| Mutex::new(State::default()))
}
fn lock() -> MutexGuard<'static, State> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
fn clear_state(st: &mut State) {
st.overlay_id = None;
st.question.clear();
st.answer = None;
st.error = None;
st.refresh_pending = false;
st.provider = None;
st.model_id = None;
st.excerpt.clear();
st.history.clear();
st.request_gen = st.request_gen.wrapping_add(1);
}
pub struct Btw;
impl Extension for Btw {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Ask a read-only side question (/btw <question>); the answer is never added to the conversation"
}
fn default_enabled(&self) -> bool {
false
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: "btw".to_string(),
description:
"/btw <question> (read-only side question; never added to the conversation)"
.to_string(),
busy_safe: true, subcommands: Vec::new(),
}]
}
fn on_registered(&self) {
register_slash_command(EXT, "btw", command_btw);
}
fn on_session_start(&self, _cwd: &str, _messages: &[AgentMessage], session: Option<&Session>) {
shared::remember_session(session.map(|s| s.session_id.clone()));
}
fn on_session_switched(&self, session_path: Option<&str>, _messages: &[AgentMessage]) {
shared::remember_session(session_id_of(session_path));
let previous = {
let mut st = lock();
let previous = st.overlay_id;
clear_state(&mut st);
previous
};
if let Some(id) = previous {
request_ui(ExtensionUiRequest::HideOverlay { id });
}
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
clear_state(&mut lock());
}
}
fn hooks(&self) -> Vec<ExtensionHook> {
if lock().overlay_id.is_some() {
vec![ExtensionHook::Overlay]
} else {
Vec::new()
}
}
fn wants_redraw(&self) -> bool {
let st = lock();
if st.overlay_id.is_none() {
return false;
}
st.refresh_pending || (st.answer.is_none() && st.error.is_none())
}
fn overlay_view(&self, id: u64, cols: u16) -> Option<OverlayView> {
let mut st = lock();
if st.overlay_id != Some(id) {
return None;
}
st.refresh_pending = false;
let width = usize::from(cols).max(20).saturating_sub(4);
let mut lines: Vec<Vec<DockSpan>> = Vec::new();
for (question, answer) in &st.history {
push_question(&mut lines, question, width);
for line in wrap_text(answer, width) {
lines.push(vec![DockSpan::plain(line)]);
}
lines.push(Vec::new());
}
push_question(&mut lines, &st.question, width);
lines.push(Vec::new());
if let Some(answer) = &st.answer {
for line in wrap_text(answer, width) {
lines.push(vec![DockSpan::plain(line)]);
}
} else if let Some(error) = &st.error {
for (i, line) in wrap_text(error, width.saturating_sub(2))
.into_iter()
.enumerate()
{
let prefix = if i == 0 {
&format!("{DEF_FAILED} ")
} else {
" "
};
lines.push(vec![DockSpan::new(
ERROR.0,
ERROR.1,
format!("{prefix}{line}"),
)]);
}
} else {
lines.push(vec![DockSpan::new(
ACCENT.0,
ACCENT.1,
format!("{} answering…", spinner_frame()),
)]);
}
Some(OverlayView {
title: "BTW".to_string(),
header: Vec::new(),
lines,
messages: Vec::new(),
footer: vec![
("Enter".to_string(), "ask".to_string()),
("Esc".to_string(), "close".to_string()),
],
input: Some(OverlayInput {
label: "btw>".to_string(),
value: String::new(),
placeholder: "ask a follow-up…".to_string(),
}),
editor: None,
size: OverlaySize::Panel { max_rows: MAX_ROWS },
selected: None,
input_focus: true,
})
}
fn on_overlay_event(&self, id: u64, ev: &OverlayEvent) -> bool {
match ev {
OverlayEvent::Closed => {
let mut st = lock();
if st.overlay_id == Some(id) {
clear_state(&mut st);
return true;
}
false
}
OverlayEvent::Submit { text } => {
let text = text.trim().to_string();
let current = {
let st = lock();
if st.overlay_id != Some(id) {
return false;
}
(st.question.clone(), st.answer.clone())
};
if text.is_empty() {
close_panel(id);
return true;
}
let (question, answer) = current;
if let Some(answer) = answer {
lock().history.push((question, answer));
}
ask(text);
true
}
OverlayEvent::Key {
key: OverlayKey::Esc,
..
} => {
if lock().overlay_id != Some(id) {
return false;
}
close_panel(id);
true
}
_ => false,
}
}
}
fn close_panel(id: u64) {
{
let mut st = lock();
if st.overlay_id == Some(id) {
clear_state(&mut st);
}
}
request_ui(ExtensionUiRequest::HideOverlay { id });
}
fn push_question(lines: &mut Vec<Vec<DockSpan>>, question: &str, width: usize) {
for line in wrap_text(question, width) {
lines.push(vec![DockSpan::new(MUTED.0, MUTED.1, line)]);
}
}
fn command_btw(st: &mut App, raw: &str) -> bool {
let question = command_arg(raw).trim().to_string();
if question.is_empty() {
st.push_msg(
"Usage: /btw <question> (read-only side question; the answer is not added to the conversation)"
.to_string(),
MsgLevel::Info,
);
return false;
}
let Some((provider, model_id)) = shared::current_model(st) else {
st.push_msg(
"No model is selected yet. Pick one with /model, then ask again.".to_string(),
MsgLevel::Warning,
);
return false;
};
shared::refresh_session_from_app(st);
let excerpt = shared::build_excerpt(&st.messages, EXCERPT_MESSAGES, EXCERPT_CHARS);
let id = next_ui_id();
{
let mut state = lock();
state.overlay_id = Some(id);
state.provider = Some(provider);
state.model_id = Some(model_id);
state.excerpt = excerpt;
state.history.clear();
state.question.clear();
state.answer = None;
state.error = None;
}
request_ui(ExtensionUiRequest::ShowOverlay { id });
ask(question);
false
}
fn ask(question: String) -> bool {
let (provider, model_id, prompt) = {
let st = lock();
let (Some(provider), Some(model_id)) = (st.provider.clone(), st.model_id.clone()) else {
return false;
};
(provider, model_id, build_prompt(&st, &question))
};
let generation = {
let mut st = lock();
st.question = question;
st.answer = None;
st.error = None;
st.request_gen = st.request_gen.wrapping_add(1);
st.request_gen
};
if let Err(e) = shared::spawn_one_shot(
provider,
model_id,
SYSTEM,
prompt,
MAX_TOKENS,
move |result| {
let mut st = lock();
if st.request_gen != generation {
return;
}
match result {
Ok(text) => st.answer = Some(text),
Err(e) => st.error = Some(e),
}
st.refresh_pending = true;
},
) {
let mut st = lock();
if st.request_gen == generation {
st.error = Some(e);
st.refresh_pending = true;
}
}
true
}
fn build_prompt(st: &State, question: &str) -> String {
let mut prompt = String::new();
if !st.excerpt.trim().is_empty() {
prompt.push_str("Session excerpt:\n");
prompt.push_str(&st.excerpt);
prompt.push_str("\n\n");
}
if !st.history.is_empty() {
prompt.push_str("Earlier side questions in this panel:\n");
let start = st.history.len().saturating_sub(PROMPT_HISTORY_TURNS);
for (question, answer) in &st.history[start..] {
prompt.push_str("Q: ");
prompt.push_str(question);
prompt.push_str("\nA: ");
prompt.push_str(answer);
prompt.push('\n');
}
prompt.push('\n');
}
prompt.push_str("Question: ");
prompt.push_str(question);
prompt
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::AUTH_TEST_LOCK;
fn lock_global() -> MutexGuard<'static, ()> {
AUTH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn message(role: &str, text: &str) -> AgentMessage {
let mut message = AgentMessage::user_text(text);
message.role = role.to_string();
message
}
fn reset_state() {
*lock() = State::default();
}
fn panel_text(view: &OverlayView) -> String {
view.lines
.iter()
.map(|line| line.iter().map(|s| s.text.clone()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn btw_without_question_prints_usage() {
let _g = lock_global();
reset_state();
let mut st = App::new();
assert!(!command_btw(&mut st, "btw"));
assert!(!st.system_messages.is_empty(), "无参应提示用法");
assert_eq!(lock().overlay_id, None, "不该打开面板");
}
#[test]
fn btw_without_model_warns() {
let _g = lock_global();
reset_state();
let mut st = App::new();
st.current_provider = None;
st.current_model = None;
assert!(!command_btw(&mut st, "btw what is this?"));
assert!(!st.system_messages.is_empty(), "没有型号应提示");
assert_eq!(lock().overlay_id, None);
}
#[test]
fn long_error_is_wrapped_instead_of_truncated() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.question = "q".to_string();
st.error = Some("provider returned 400 Bad Request: ".to_string() + &"x".repeat(200));
}
let ext = Btw;
let text = panel_text(&ext.overlay_view(id, 40).unwrap());
let lines: Vec<&str> = text.lines().collect();
assert!(lines.len() > 3, "长错误必须折成多行,而不是一行被截断");
assert!(
lines.iter().all(|l| l.chars().count() <= 40),
"每行都不该超出面板宽度:{lines:?}"
);
assert!(lines.iter().any(|l| l.contains('✗')), "首行带错误标记");
}
#[test]
fn btw_recovers_session_id_from_app_path() {
let _g = lock_global();
reset_state();
shared::remember_session(None);
let mut st = App::new();
st.current_provider = Some("opencode".to_string());
st.current_model = Some("kimi-k2.5".to_string());
st.current_session_path =
Some("/tmp/0193f1b2-1111-2222-3333-444455556666.jsonl".to_string());
assert!(!command_btw(&mut st, "btw hi"));
assert_eq!(
shared::remembered_session().as_deref(),
Some("0193f1b2-1111-2222-3333-444455556666"),
"从 App 的会话路径回填 id,opencode 才有 x-opencode-session"
);
}
#[test]
fn answer_landing_requests_one_more_frame() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.answer = Some("done".to_string());
st.refresh_pending = true;
}
let ext = Btw;
assert!(ext.wants_redraw(), "结果刚落地的下一帧必须重绘");
let _ = ext.overlay_view(id, 80).unwrap();
assert!(!ext.wants_redraw(), "渲染过一帧后不再持续重绘");
}
#[test]
fn btw_opens_panel_and_renders_error_without_runtime() {
let _g = lock_global();
reset_state();
let mut st = App::new();
st.current_provider = Some("anthropic".to_string());
st.current_model = Some("claude-fable-5".to_string());
st.messages.push(message("user", "hello world"));
assert!(!command_btw(&mut st, "btw what did I ask?"));
let id = lock().overlay_id.expect("应打开面板");
assert!(lock().error.is_some(), "单测没有 tokio 运行时应记录错误");
let ext = Btw;
let view = ext.overlay_view(id, 80).expect("认领自己的 id");
assert_eq!(view.title, "BTW");
assert!(
matches!(view.size, OverlaySize::Panel { .. }),
"面板要占据输入区、隐藏主页输入框"
);
assert!(view.input.is_some(), "面板要自带追问输入行");
assert!(view.input_focus, "打开即聚焦输入行");
assert!(ext.overlay_view(id + 1, 80).is_none());
let text = panel_text(&view);
assert!(text.contains("what did I ask?"), "面板要显示问题");
assert!(text.contains('✗'), "面板要显示错误");
lock().error = None;
lock().answer = Some("you asked about the thing".to_string());
assert!(!ext.wants_redraw());
assert!(
panel_text(&ext.overlay_view(id, 80).unwrap()).contains("you asked about the thing")
);
assert!(ext.on_overlay_event(id, &OverlayEvent::Closed));
assert_eq!(lock().overlay_id, None);
assert!(!ext.on_overlay_event(id, &OverlayEvent::Closed));
}
#[test]
fn late_answer_is_dropped_when_panel_closed() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.question = "q".to_string();
}
Btw.on_overlay_event(id, &OverlayEvent::Closed);
let st = lock();
assert_eq!(st.overlay_id, None);
assert!(st.answer.is_none());
}
#[test]
fn empty_submit_closes_and_text_submit_starts_a_new_turn() {
let _g = lock_global();
reset_state();
while crate::core::extensions::take_pending_ui().is_some() {}
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.provider = Some("anthropic".to_string());
st.model_id = Some("claude-fable-5".to_string());
st.excerpt = "user: hi".to_string();
st.question = "first?".to_string();
st.answer = Some("first answer".to_string());
}
assert!(Btw.on_overlay_event(
id,
&OverlayEvent::Submit {
text: " follow up ".to_string()
}
));
{
let st = lock();
assert_eq!(
st.history,
vec![("first?".to_string(), "first answer".to_string())],
"完成的一轮要进历史"
);
assert_eq!(st.question, "follow up", "新问题要 trim 后成为当前问题");
assert!(st.answer.is_none(), "新一轮还没有答案");
assert!(st.error.is_some(), "单测没有 tokio 运行时 → 记录错误");
}
assert!(Btw.on_overlay_event(
id,
&OverlayEvent::Submit {
text: String::new()
}
));
assert_eq!(lock().overlay_id, None, "空回车应关面板");
assert!(matches!(
crate::core::extensions::take_pending_ui(),
Some(ExtensionUiRequest::HideOverlay { id: hid }) if hid == id
));
}
#[test]
fn escape_closes_the_panel_on_the_first_press() {
let _g = lock_global();
reset_state();
while crate::core::extensions::take_pending_ui().is_some() {}
let id = next_ui_id();
lock().overlay_id = Some(id);
assert!(Btw.on_overlay_event(
id,
&OverlayEvent::Key {
key: OverlayKey::Esc,
input: Some(String::new()),
}
));
assert_eq!(lock().overlay_id, None, "Esc 应关面板");
assert!(matches!(
crate::core::extensions::take_pending_ui(),
Some(ExtensionUiRequest::HideOverlay { id: hid }) if hid == id
));
}
#[test]
fn question_and_answer_lines_are_left_aligned() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.question = "question".to_string();
st.answer = Some("answer".to_string());
}
let text = panel_text(&Btw.overlay_view(id, 40).unwrap());
let question = text.lines().find(|l| l.contains("question")).unwrap();
let answer = text.lines().find(|l| l.contains("answer")).unwrap();
assert_eq!(question, "question", "问题不应带左侧 padding");
assert_eq!(answer, "answer", "答案不带左侧 padding");
}
#[test]
fn follow_up_prompt_carries_excerpt_and_history() {
let _g = lock_global();
reset_state();
let st = State {
excerpt: "user: do the thing".to_string(),
history: vec![("what now?".to_string(), "keep going".to_string())],
..State::default()
};
let prompt = build_prompt(&st, "and then?");
assert!(prompt.contains("user: do the thing"), "带会话摘录");
assert!(prompt.contains("Q: what now?"), "带历史问题");
assert!(prompt.contains("A: keep going"), "带历史答案");
assert!(
prompt.trim_end().ends_with("Question: and then?"),
"末行是本次问题"
);
let bare = build_prompt(&State::default(), "hi");
assert_eq!(bare, "Question: hi", "没有摘录/历史时只有问题");
}
#[test]
fn disabled_extension_clears_state_and_hooks() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
{
let mut st = lock();
st.overlay_id = Some(id);
st.question = "q".to_string();
}
assert_eq!(Btw.hooks(), vec![ExtensionHook::Overlay]);
assert!(Btw.wants_redraw());
Btw.on_enabled_changed(false);
assert_eq!(lock().overlay_id, None);
assert!(!Btw.wants_redraw());
assert!(Btw.hooks().is_empty());
}
#[test]
fn command_and_mode_surface() {
let cmds = Btw.commands();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name, "btw");
assert!(cmds[0].busy_safe);
assert!(cmds[0].subcommands.is_empty());
assert_eq!(Btw.name(), EXT);
assert!(!Btw.default_enabled(), "默认不启用");
assert_eq!(
Btw.modes(),
vec![ExtensionMode::Dev, ExtensionMode::Creator],
"只在 Dev / Creator 模式可用"
);
assert!(Btw.tools().is_empty());
}
#[test]
fn session_switch_remembers_id_and_closes_panel() {
let _g = lock_global();
reset_state();
let id = next_ui_id();
lock().overlay_id = Some(id);
while crate::core::extensions::take_pending_ui().is_some() {}
Btw.on_session_switched(Some("/tmp/0193f1b2-1111-2222-3333-444455556666.jsonl"), &[]);
assert_eq!(lock().overlay_id, None, "会话换了就收掉面板");
assert!(matches!(
crate::core::extensions::take_pending_ui(),
Some(ExtensionUiRequest::HideOverlay { id: hid }) if hid == id
));
}
}