use bcode_model::SessionModelStatus;
use bmux_keyboard::KeyStroke;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkingDialogOutcome {
Handled,
Apply,
Cancel,
Ignored,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThinkingDialogFocus {
Display,
Mode,
Effort,
Summary,
}
impl ThinkingDialogFocus {
const fn row(self) -> usize {
match self {
Self::Display => 0,
Self::Mode => 1,
Self::Effort => 2,
Self::Summary => 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThinkingDialogState {
supported: bool,
visible: bool,
mode: bcode_config::TuiThinkingMode,
effort: Option<String>,
summary: Option<String>,
effort_values: Vec<String>,
summary_values: Vec<String>,
default_effort: Option<String>,
default_summary: Option<String>,
source: bcode_model::ModelReasoningCapabilitySource,
focused_row: usize,
}
impl ThinkingDialogState {
#[must_use]
pub fn new(
visible: bool,
mode: bcode_config::TuiThinkingMode,
status: &SessionModelStatus,
) -> Self {
let reasoning = status.reasoning.as_ref();
let source = reasoning.map_or(
bcode_model::ModelReasoningCapabilitySource::GenericFallback,
|reasoning| reasoning.source,
);
Self {
supported: reasoning.is_some(),
visible,
mode,
effort: status.reasoning_effort.clone(),
summary: status.reasoning_summary.clone(),
effort_values: reasoning
.map(|reasoning| {
bcode_model::ordered_reasoning_effort_values(&reasoning.effort_values)
})
.unwrap_or_default(),
summary_values: reasoning
.map(|reasoning| reasoning.summary_values.clone())
.unwrap_or_default(),
default_effort: reasoning.and_then(|reasoning| reasoning.default_effort.clone()),
default_summary: reasoning.and_then(|reasoning| reasoning.default_summary.clone()),
source,
focused_row: ThinkingDialogFocus::Display.row(),
}
}
#[must_use]
pub fn new_focused(
visible: bool,
mode: bcode_config::TuiThinkingMode,
status: &SessionModelStatus,
focus: ThinkingDialogFocus,
) -> Self {
let mut state = Self::new(visible, mode, status);
state.focused_row = focus.row();
state
}
#[must_use]
pub const fn supported(&self) -> bool {
self.supported
}
#[must_use]
pub const fn visible(&self) -> bool {
self.visible
}
#[must_use]
pub const fn mode(&self) -> bcode_config::TuiThinkingMode {
self.mode
}
#[must_use]
pub const fn mode_label(&self) -> &'static str {
match self.mode {
bcode_config::TuiThinkingMode::All => "all",
bcode_config::TuiThinkingMode::Summary => "summaries/milestones",
bcode_config::TuiThinkingMode::Raw => "raw details",
}
}
#[must_use]
pub fn effort(&self) -> Option<&str> {
self.effort.as_deref()
}
#[must_use]
pub fn summary(&self) -> Option<&str> {
self.summary.as_deref()
}
#[must_use]
pub fn effort_values(&self) -> &[String] {
&self.effort_values
}
#[must_use]
pub const fn effort_values_source(&self) -> bcode_model::ModelReasoningCapabilitySource {
self.source
}
#[must_use]
pub fn summary_values(&self) -> &[String] {
&self.summary_values
}
#[must_use]
pub const fn summary_values_source(&self) -> bcode_model::ModelReasoningCapabilitySource {
self.source
}
#[must_use]
pub fn effective_effort_label(&self) -> &str {
self.effort
.as_deref()
.or(self.default_effort.as_deref())
.unwrap_or("provider default")
}
#[must_use]
pub fn effective_summary_label(&self) -> &str {
self.summary
.as_deref()
.or(self.default_summary.as_deref())
.unwrap_or("not requested")
}
#[must_use]
pub const fn focused_row(&self) -> usize {
self.focused_row
}
pub const fn focus_next(&mut self) {
self.focused_row = self.focused_row.saturating_add(1) % Self::row_count();
}
pub const fn focus_previous(&mut self) {
if self.focused_row == 0 {
self.focused_row = Self::row_count().saturating_sub(1);
} else {
self.focused_row = self.focused_row.saturating_sub(1);
}
}
#[cfg(test)]
pub fn handle_key(&mut self, stroke: KeyStroke) -> ThinkingDialogOutcome {
self.handle_key_with_keymap(
stroke,
&super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
)
}
pub fn handle_key_with_keymap(
&mut self,
stroke: KeyStroke,
keymap: &super::keymap::BmuxKeyMap,
) -> ThinkingDialogOutcome {
match keymap.action("thinking_dialog", stroke) {
Some("tui.control.up") => {
self.focus_previous();
ThinkingDialogOutcome::Handled
}
Some("tui.control.down") => {
self.focus_next();
ThinkingDialogOutcome::Handled
}
Some("tui.control.toggle") => {
self.cycle_focused();
ThinkingDialogOutcome::Handled
}
Some("tui.control.submit") => ThinkingDialogOutcome::Apply,
Some("tui.control.cancel") => ThinkingDialogOutcome::Cancel,
_ => ThinkingDialogOutcome::Ignored,
}
}
pub fn cycle_focused(&mut self) {
match self.focused_row {
0 => self.visible = !self.visible,
1 => {
self.mode = match self.mode {
bcode_config::TuiThinkingMode::All => bcode_config::TuiThinkingMode::Summary,
bcode_config::TuiThinkingMode::Summary => bcode_config::TuiThinkingMode::Raw,
bcode_config::TuiThinkingMode::Raw => bcode_config::TuiThinkingMode::All,
};
}
2 if self.supported => {
self.effort = next_value(self.effort.as_deref(), &self.effort_values);
}
3 if self.supported => {
self.summary = next_value(self.summary.as_deref(), &self.summary_values);
}
_ => {}
}
}
const fn row_count() -> usize {
4
}
}
fn next_value(current: Option<&str>, values: &[String]) -> Option<String> {
if values.is_empty() {
return current.map(ToOwned::to_owned);
}
let next_index = current
.and_then(|current| values.iter().position(|value| value == current))
.map_or(0, |index| index.saturating_add(1) % values.len());
values.get(next_index).cloned()
}
#[cfg(test)]
mod tests {
use super::{ThinkingDialogOutcome, ThinkingDialogState};
use bmux_keyboard::{KeyCode, KeyStroke};
fn status() -> bcode_model::SessionModelStatus {
bcode_model::SessionModelStatus {
provider_plugin_id: None,
requested_model_id: None,
effective_model_id: None,
model_id: None,
display_name: None,
context_window: None,
context_occupancy: None,
request_context_error: None,
auth_profile: None,
context_format_version: None,
compatibility_key: None,
max_output_tokens: None,
reasoning: None,
reasoning_effort: None,
reasoning_summary: None,
prompt_cache_mode: None,
conversation_reuse_mode: None,
compaction_mode: None,
compaction_backend: None,
proactive_compaction_threshold_percent: None,
proactive_compaction_threshold_tokens: None,
proactive_compaction_effective_threshold_tokens: None,
proactive_compaction_threshold_source: None,
cache: None,
metadata_source: None,
pricing: None,
}
}
#[test]
fn keyboard_policy_owns_focus_changes_apply_and_cancel() {
let mut dialog =
ThinkingDialogState::new(false, bcode_config::TuiThinkingMode::All, &status());
assert_eq!(
dialog.handle_key(KeyStroke::simple(KeyCode::Down)),
ThinkingDialogOutcome::Handled
);
assert_eq!(dialog.focused_row(), 1);
assert_eq!(
dialog.handle_key(KeyStroke::simple(KeyCode::Enter)),
ThinkingDialogOutcome::Apply
);
assert_eq!(
dialog.handle_key(KeyStroke::simple(KeyCode::Escape)),
ThinkingDialogOutcome::Cancel
);
assert_eq!(
dialog.handle_key(KeyStroke::simple(KeyCode::Char('x'))),
ThinkingDialogOutcome::Ignored
);
}
}