use super::*;
#[derive(Debug)]
pub(crate) struct TranscriptModal {
pub(crate) state: MissionControlState,
pub(crate) visible: bool,
pub(crate) active: bool,
pub(crate) unread: bool,
}
impl TranscriptModal {
pub(crate) fn new(mut state: MissionControlState) -> Self {
state.transcript_only_layout = true;
state.welcome_render_cache.get_mut().take();
state.focus_prompt();
Self {
state,
visible: true,
active: false,
unread: false,
}
}
fn modal_area(area: Rect) -> Rect {
crate::tui::modal_container::modal_area(area, 60, 60)
}
pub(crate) fn content_area(area: Rect) -> Rect {
Self::modal_area(area).inner(Margin {
horizontal: 1,
vertical: 1,
})
}
pub(super) fn draw(&self, frame: &mut Frame<'_>) {
if !self.visible {
if self.active || self.unread {
let label = match (self.active, self.unread) {
(true, true) => " /side running • unread ",
(true, false) => " /side running ",
_ => " /side unread ",
};
let area = frame.area();
let width = (label.len() as u16).min(area.width);
frame.render_widget(
Paragraph::new(label).style(self.state.theme.app()),
Rect::new(area.right().saturating_sub(width), area.y, width, 1),
);
}
return;
}
let area = Self::modal_area(frame.area());
let content = crate::tui::modal_container::draw_modal_container(
frame,
area,
"Side",
"[Alt+C] cancel · /reset",
self.state.theme,
Some("[Esc] close"),
);
super::draw_in_area(frame, content, &self.state);
}
}
impl MissionControlState {
pub(crate) fn focused_conversation_mut(&mut self) -> &mut Self {
if self
.conversation_modal
.as_ref()
.is_some_and(|modal| modal.visible)
{
&mut self
.conversation_modal
.as_mut()
.expect("visible modal")
.state
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn transcript_modal_is_compact_centered_and_uses_full_width_for_hit_testing() {
let terminal = Rect::new(3, 2, 100, 40);
assert_eq!(
TranscriptModal::modal_area(terminal),
Rect::new(23, 10, 60, 24)
);
let content = TranscriptModal::content_area(terminal);
assert_eq!(content, Rect::new(24, 11, 58, 22));
let modal = TranscriptModal::new(MissionControlState::default());
let layout = layout_for(content, &modal.state);
assert_eq!(layout.transcript.x, content.x);
assert_eq!(layout.transcript.width, content.width);
assert_eq!(layout.prompt.bottom(), content.bottom());
assert_eq!(
layout.pane_at(content.right() - 1, content.y),
Some(TuiPane::Transcript)
);
assert_eq!(
layout.pane_at(content.right() - 1, content.bottom() - 1),
Some(TuiPane::Prompt)
);
assert_eq!(layout.pane_at(content.x - 1, content.y), None);
for (width, height) in [(0, 0), (1, 1), (5, 3), (20, 6), (39, 11)] {
let area = Rect::new(0, 0, width, height);
let modal = TranscriptModal::modal_area(area);
assert_eq!(modal.intersection(area), modal);
assert!(u32::from(modal.width) * 100 <= u32::from(width) * 80);
assert!(u32::from(modal.height) * 100 <= u32::from(height) * 80);
}
}
#[test]
fn transcript_modal_shell_separates_close_from_worker_cancel() {
let modal = TranscriptModal::new(MissionControlState::default());
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal.draw(|frame| modal.draw(frame)).unwrap();
let area = TranscriptModal::modal_area(Rect::new(0, 0, 100, 30));
let buffer = terminal.backend().buffer();
let row = |y| {
(area.x..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
};
assert!(row(area.y).starts_with("┌Side"));
assert!(row(area.y).contains("[Esc] close"));
assert!(!row(area.y).contains("cancel"));
assert!(row(area.bottom() - 1).contains("[Alt+C] cancel · /reset"));
}
#[test]
fn transcript_modal_keeps_panes_absent_after_shortcuts_reset_and_reopen() {
let area = Rect::new(0, 0, 160, 60);
let primary = MissionControlState::default();
let primary_layout = layout_for(area, &primary);
assert!(primary_layout.activity_tree.is_some());
assert!(primary_layout.right_column_rail.is_some());
let mut modal = TranscriptModal::new(MissionControlState::default());
for tab in [RightColumnTab::Activity, RightColumnTab::Summary] {
modal.state.select_right_column_tab(tab);
modal.state.toggle_activity_column();
modal.state.focus_activity_layout();
modal.state.focus_activity_tree();
modal.state.focus_activity_detail();
modal.state.focus_summary();
assert!(modal.state.is_prompt_focused());
modal.state.focus_next_primary_pane();
assert!(modal.state.is_transcript_focused());
modal.state.focus_next_primary_pane();
assert!(modal.state.is_prompt_focused());
modal.state.focus_previous_primary_pane();
assert!(modal.state.is_transcript_focused());
modal.state.focus_previous_primary_pane();
assert!(modal.state.is_prompt_focused());
modal.state.reset_for_new_session();
let layout = layout_for(TranscriptModal::content_area(area), &modal.state);
assert!(layout.activity_tree.is_none());
assert!(layout.detail.is_none());
assert!(layout.summary.is_none());
assert!(layout.right_column_rail.is_none());
assert_eq!(layout.right_column_tab_area(tab), None);
}
}
#[test]
fn transcript_modal_uses_exact_normal_card_and_prompt_projection() {
let mut ui = MissionControlState::default();
ui.apply_output_event(&crate::output::OutputEvent::Diagnostic {
level: "info".into(),
message: "Independent side history".into(),
});
ui.set_prompt_text("side draft", 10);
ui.focus_prompt();
let modal = TranscriptModal::new(ui);
let mut actual = Terminal::new(TestBackend::new(100, 30)).unwrap();
actual.draw(|frame| modal.draw(frame)).unwrap();
let rendered: String = actual
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("Session:"), "{rendered}");
assert!(!rendered.contains("[Alt-A]"), "{rendered}");
assert!(!rendered.contains("Show/Hide Panel"), "{rendered}");
let mut expected = Terminal::new(TestBackend::new(100, 30)).unwrap();
expected
.draw(|frame| {
super::super::draw_in_area(
frame,
TranscriptModal::content_area(frame.area()),
&modal.state,
)
})
.unwrap();
let area = TranscriptModal::content_area(Rect::new(0, 0, 100, 30));
for y in area.y..area.bottom() {
for x in area.x..area.right() {
assert_eq!(
actual.backend().buffer()[(x, y)],
expected.backend().buffer()[(x, y)]
);
}
}
}
#[test]
fn transcript_modal_handles_tiny_terminals_and_hidden_indicator() {
for (width, height) in [(1, 1), (5, 3), (20, 6), (80, 24)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut modal = TranscriptModal::new(MissionControlState::default());
terminal.draw(|frame| modal.draw(frame)).unwrap();
modal.visible = false;
modal.active = true;
modal.unread = true;
terminal.draw(|frame| modal.draw(frame)).unwrap();
if width >= 80 {
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("/side running"));
assert!(text.contains("unread"));
}
}
}
}