use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::Frame;
use crate::events::{
ClaudeOptions, ClaudeSessionMode, CodexOptions, Command, SessionSpec, SpecOptions,
};
use crate::store::Recent;
use crate::ui::Theme;
use super::recents::RecentsModal;
use super::{center_rect, Modal, ModalData, ModalResult};
const MODAL_WIDTH: u16 = 64;
const PATH_SUGGESTION_CAP: usize = 50;
const DROPDOWN_MAX_VISIBLE: usize = 8;
pub const AGENTS: &[&str] = &["claude", "codex", "terminal"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Name,
Path,
Agent,
Args,
ClaudeSession,
ClaudeSkipPerm,
CodexYolo,
}
impl Field {
fn visible_for(agent: &str) -> Vec<Field> {
let mut v = vec![Field::Name, Field::Path, Field::Agent, Field::Args];
match agent {
"claude" => {
v.push(Field::ClaudeSession);
v.push(Field::ClaudeSkipPerm);
}
"codex" => {
v.push(Field::CodexYolo);
}
_ => {}
}
v
}
}
pub struct NewSessionModal {
name: String,
path: String,
agent_idx: usize,
args: String,
claude: ClaudeOptions,
codex: CodexOptions,
field: Field,
error: Option<String>,
recents: Vec<Recent>,
path_suggestion_idx: Option<usize>,
path_suggestion_scroll: usize,
path_dropdown_active: bool,
}
#[derive(Debug, Clone)]
struct PathEntry {
name: String,
is_dir: bool,
}
impl NewSessionModal {
pub fn new(recents: Vec<Recent>) -> Self {
let path = recents
.first()
.map(|r| r.path.clone())
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
})
.unwrap_or_else(|| "~".to_string());
let mut modal = Self {
name: String::new(),
path,
agent_idx: 0,
args: String::new(),
claude: ClaudeOptions::default(),
codex: CodexOptions::default(),
field: Field::Name,
error: None,
recents,
path_suggestion_idx: None,
path_suggestion_scroll: 0,
path_dropdown_active: true,
};
modal.apply_remembered_options();
modal
}
fn path_suggestions(&self) -> Vec<PathEntry> {
read_dir_filtered(&self.path, PATH_SUGGESTION_CAP)
}
fn path_suggestions_all(&self) -> Vec<PathEntry> {
read_dir_filtered(&self.path, usize::MAX)
}
fn commit_path_entry(&mut self, entry: &PathEntry) {
let (dir, _prefix) = split_path(&self.path);
let mut new_path = format!("{}{}", dir, entry.name);
if entry.is_dir {
new_path.push('/');
}
self.path = new_path;
self.reset_path_dropdown();
}
fn reset_path_dropdown(&mut self) {
self.path_suggestion_idx = None;
self.path_suggestion_scroll = 0;
self.path_dropdown_active = true;
}
fn clamp_dropdown_scroll(&mut self, count: usize) {
if let Some(idx) = self.path_suggestion_idx {
let max_vis = DROPDOWN_MAX_VISIBLE.min(count);
if idx < self.path_suggestion_scroll {
self.path_suggestion_scroll = idx;
} else if max_vis > 0 && idx >= self.path_suggestion_scroll + max_vis {
self.path_suggestion_scroll = idx + 1 - max_vis;
}
} else {
self.path_suggestion_scroll = 0;
}
}
fn tab_complete_path(&mut self) -> bool {
if !self.path_dropdown_active {
return false;
}
let suggestions = self.path_suggestions_all();
if suggestions.is_empty() {
return false;
}
let (dir, prefix) = split_path(&self.path);
if suggestions.len() == 1 {
self.commit_path_entry(&suggestions[0]);
return true;
}
let names: Vec<&str> = suggestions.iter().map(|e| e.name.as_str()).collect();
let lcp = longest_common_prefix(&names);
if lcp.chars().count() > prefix.chars().count() {
self.path = format!("{}{}", dir, lcp);
self.reset_path_dropdown();
}
true
}
fn fill_from_spec(&mut self, spec: SessionSpec) {
self.name = spec.name;
self.path = spec.path;
self.args = spec.args;
self.claude = spec.options.claude;
self.codex = spec.options.codex;
if let Some(idx) = AGENTS.iter().position(|a| *a == spec.agent) {
self.agent_idx = idx;
}
self.error = None;
self.field = Field::Name;
}
fn agent(&self) -> &'static str {
AGENTS[self.agent_idx]
}
fn next_field(&mut self) {
let visible = Field::visible_for(self.agent());
let idx = visible.iter().position(|f| *f == self.field).unwrap_or(0);
self.field = visible[(idx + 1) % visible.len()];
if self.field == Field::Path {
self.path_dropdown_active = true;
}
}
fn prev_field(&mut self) {
let visible = Field::visible_for(self.agent());
let idx = visible.iter().position(|f| *f == self.field).unwrap_or(0);
self.field = visible[(idx + visible.len() - 1) % visible.len()];
if self.field == Field::Path {
self.path_dropdown_active = true;
}
}
fn clamp_field_for_agent(&mut self) {
let visible = Field::visible_for(self.agent());
if !visible.contains(&self.field) {
self.field = Field::Agent;
}
}
fn apply_remembered_options(&mut self) {
match self.agent() {
"claude" => {
if let Some(r) = self.recents.iter().find(|r| r.agent == "claude") {
self.claude = r.claude.clone();
}
}
"codex" => {
if let Some(r) = self.recents.iter().find(|r| r.agent == "codex") {
self.codex = r.codex.clone();
}
}
_ => {}
}
}
fn on_agent_changed(&mut self) {
self.clamp_field_for_agent();
self.apply_remembered_options();
}
fn modal_height(&self) -> u16 {
let mut h: u16 = 13;
match self.agent() {
"claude" => h += 4, "codex" => h += 3, _ => {}
}
if self.error.is_some() {
h += 2; }
h + 2
}
fn build_spec(&self) -> Result<SessionSpec, String> {
let name = self.name.trim();
if name.is_empty() {
return Err("name is required".into());
}
let name = name.strip_prefix("bosun-").unwrap_or(name);
if !name.chars().any(|c| c.is_alphanumeric()) {
return Err("name must contain at least one letter or digit".into());
}
let path = self.path.trim();
if path.is_empty() {
return Err("path is required".into());
}
Ok(SessionSpec {
name: name.to_string(),
path: path.to_string(),
agent: self.agent().to_string(),
args: self.args.trim().to_string(),
options: SpecOptions {
claude: self.claude.clone(),
codex: self.codex.clone(),
},
})
}
}
impl Default for NewSessionModal {
fn default() -> Self {
Self::new(Vec::new())
}
}
impl Modal for NewSessionModal {
fn id(&self) -> &'static str {
"new_session"
}
fn handle(&mut self, key: KeyEvent) -> ModalResult {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ModalResult::Close(None);
}
if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ModalResult::Push(Box::new(RecentsModal::new(self.recents.clone())));
}
match key.code {
KeyCode::Esc => {
if self.field == Field::Path && self.path_dropdown_active {
self.path_dropdown_active = false;
self.path_suggestion_idx = None;
return ModalResult::Consumed;
}
ModalResult::Close(None)
}
KeyCode::Tab => {
if self.field == Field::Path {
if let Some(idx) = self.path_suggestion_idx {
let entries = self.path_suggestions();
if let Some(entry) = entries.get(idx).cloned() {
self.commit_path_entry(&entry);
return ModalResult::Consumed;
}
}
if self.tab_complete_path() {
return ModalResult::Consumed;
}
}
self.next_field();
ModalResult::Consumed
}
KeyCode::BackTab => {
self.prev_field();
ModalResult::Consumed
}
KeyCode::Enter => {
if self.field == Field::Path {
if let Some(idx) = self.path_suggestion_idx {
let entries = self.path_suggestions();
if let Some(entry) = entries.get(idx).cloned() {
let was_dir = entry.is_dir;
self.commit_path_entry(&entry);
if !was_dir {
self.next_field();
}
return ModalResult::Consumed;
}
}
}
match self.build_spec() {
Ok(spec) => ModalResult::Close(Some(Command::CreateSession(spec))),
Err(e) => {
self.error = Some(e);
ModalResult::Consumed
}
}
}
KeyCode::Left => {
match self.field {
Field::Agent => {
self.agent_idx = (self.agent_idx + AGENTS.len() - 1) % AGENTS.len();
self.on_agent_changed();
}
Field::ClaudeSession => {
self.claude.session_mode = self.claude.session_mode.prev();
}
_ => {}
}
ModalResult::Consumed
}
KeyCode::Right => {
match self.field {
Field::Agent => {
self.agent_idx = (self.agent_idx + 1) % AGENTS.len();
self.on_agent_changed();
}
Field::ClaudeSession => {
self.claude.session_mode = self.claude.session_mode.next();
}
_ => {}
}
ModalResult::Consumed
}
KeyCode::Down if self.field == Field::Path => {
let suggestions = self.path_suggestions();
let count = suggestions.len();
if !suggestions.is_empty() {
self.path_dropdown_active = true;
self.path_suggestion_idx = Some(match self.path_suggestion_idx {
None => 0,
Some(i) if i + 1 < count => i + 1,
Some(i) => i,
});
self.clamp_dropdown_scroll(count);
}
ModalResult::Consumed
}
KeyCode::Up if self.field == Field::Path => {
self.path_suggestion_idx = match self.path_suggestion_idx {
None | Some(0) => None,
Some(i) => Some(i - 1),
};
let count = self.path_suggestions().len();
self.clamp_dropdown_scroll(count);
ModalResult::Consumed
}
KeyCode::Backspace => {
self.error = None;
match self.field {
Field::Name => {
self.name.pop();
}
Field::Path => {
self.path.pop();
self.reset_path_dropdown();
}
Field::Args => {
self.args.pop();
}
_ => {}
}
ModalResult::Consumed
}
KeyCode::Char(' ') => {
self.error = None;
match self.field {
Field::Name => self.name.push(' '),
Field::Path => {
self.path.push(' ');
self.reset_path_dropdown();
}
Field::Args => self.args.push(' '),
Field::Agent => {
self.agent_idx = (self.agent_idx + 1) % AGENTS.len();
self.on_agent_changed();
}
Field::ClaudeSkipPerm => {
self.claude.skip_permissions = !self.claude.skip_permissions;
}
Field::CodexYolo => {
self.codex.yolo = !self.codex.yolo;
}
Field::ClaudeSession => {
self.claude.session_mode = self.claude.session_mode.next();
}
}
ModalResult::Consumed
}
KeyCode::Char(c) => {
self.error = None;
match self.field {
Field::Name => self.name.push(c),
Field::Path => {
self.path.push(c);
self.reset_path_dropdown();
}
Field::Args => self.args.push(c),
_ => {}
}
ModalResult::Consumed
}
_ => ModalResult::Consumed,
}
}
fn on_child_closed(&mut self, data: ModalData) {
let ModalData::FillSessionSpec(spec) = data;
self.fill_from_spec(spec);
}
fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
let rect = center_rect(area, MODAL_WIDTH, self.modal_height());
let body_bg = theme.panel_alt;
let buf = frame.buffer_mut();
if rect.x + rect.width < area.x + area.width && rect.y + rect.height < area.y + area.height
{
let shadow = Rect::new(rect.x + 1, rect.y + 1, rect.width, rect.height);
let style = Style::default().bg(theme.shadow);
for y in shadow.top()..shadow.bottom() {
for x in shadow.left()..shadow.right() {
let cell = &mut buf[(x, y)];
cell.set_style(style);
}
}
}
let body_style = Style::default().bg(body_bg);
for y in rect.top()..rect.bottom() {
for x in rect.left()..rect.right() {
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(body_style);
}
}
let accent_style = Style::default().bg(theme.accent);
for y in rect.top()..rect.bottom() {
let cell = &mut buf[(rect.left(), y)];
cell.set_char(' ');
cell.set_style(accent_style);
}
let inner = Rect::new(
rect.x + 3,
rect.y + 1,
rect.width.saturating_sub(4),
rect.height.saturating_sub(2),
);
let mut lines: Vec<Line<'static>> = vec![
Line::from(vec![
Span::styled(
"New session",
Style::default()
.fg(theme.text)
.bg(body_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" tab next · ^r recents · esc cancel · enter create",
Style::default().fg(theme.text_muted).bg(body_bg),
),
]),
Line::from(""),
label_line("name", self.field == Field::Name, theme),
input_line(&self.name, self.field == Field::Name, inner.width, theme),
Line::from(""),
label_line("path", self.field == Field::Path, theme),
input_line(&self.path, self.field == Field::Path, inner.width, theme),
];
lines.extend([
Line::from(""),
label_line("agent", self.field == Field::Agent, theme),
agent_line(self.agent_idx, self.field == Field::Agent, theme),
Line::from(""),
label_line("args (optional)", self.field == Field::Args, theme),
input_line(&self.args, self.field == Field::Args, inner.width, theme),
]);
match self.agent() {
"claude" => {
lines.push(Line::from(""));
lines.push(section_header("— Claude options —", theme));
lines.push(session_radio_line(
self.claude.session_mode,
self.field == Field::ClaudeSession,
theme,
));
lines.push(checkbox_line(
"Skip permissions (--dangerously-skip-permissions)",
self.claude.skip_permissions,
self.field == Field::ClaudeSkipPerm,
theme,
));
}
"codex" => {
lines.push(Line::from(""));
lines.push(section_header("— Codex options —", theme));
lines.push(checkbox_line(
"YOLO mode (--yolo · bypass approvals & sandbox)",
self.codex.yolo,
self.field == Field::CodexYolo,
theme,
));
}
_ => {}
}
if let Some(e) = &self.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" ! {}", e),
Style::default().fg(theme.status_error).bg(body_bg),
)));
}
Paragraph::new(lines)
.style(Style::default().bg(body_bg))
.render(inner, frame.buffer_mut());
if self.field == Field::Path && self.path_dropdown_active {
let suggestions = self.path_suggestions();
if !suggestions.is_empty() {
let dropdown_y = inner.y + 7;
let dropdown_x = inner.x;
let avail = area.bottom().saturating_sub(dropdown_y) as usize;
let visible = suggestions.len().min(DROPDOWN_MAX_VISIBLE).min(avail);
if visible > 0 {
let scroll = self.path_suggestion_scroll;
let has_above = scroll > 0;
let has_below = scroll + visible < suggestions.len();
let buf = frame.buffer_mut();
for vi in 0..visible {
let si = scroll + vi;
let y = dropdown_y + vi as u16;
if y >= area.bottom() {
break;
}
let entry = &suggestions[si];
let highlighted = self.path_suggestion_idx == Some(si);
let bg = if highlighted {
theme.selection_bg
} else {
theme.bg
};
let fg = if highlighted {
theme.text
} else {
theme.text_muted
};
let marker = if highlighted { "▸" } else { " " };
let suffix = if entry.is_dir { "/" } else { "" };
let text = format!(" {} {}{}", marker, entry.name, suffix);
let field_w = inner.width.saturating_sub(3) as usize;
let margin_style = Style::default().bg(body_bg);
for x in dropdown_x..dropdown_x.saturating_add(3).min(area.right()) {
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(margin_style);
}
let entry_style = Style::default().fg(fg).bg(bg);
let entry_x = dropdown_x + 3;
for x in
entry_x..(entry_x + inner.width.saturating_sub(3)).min(area.right())
{
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(entry_style);
}
buf.set_string(entry_x, y, &text, entry_style);
let ind_x = entry_x + field_w as u16 - 2;
if ind_x < area.right() {
if vi == 0 && has_above {
buf.set_string(
ind_x,
y,
"▴",
Style::default().fg(theme.text_muted).bg(bg),
);
}
if vi == visible - 1 && has_below {
buf.set_string(
ind_x,
y,
"▾",
Style::default().fg(theme.text_muted).bg(bg),
);
}
}
}
}
}
}
}
}
fn label_line(label: &str, focused: bool, theme: &Theme) -> Line<'static> {
let marker = if focused { "▸" } else { " " };
let label_style = if focused {
Style::default()
.fg(theme.accent)
.bg(theme.panel_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(theme.panel_alt)
};
Line::from(vec![
Span::styled(format!(" {} ", marker), label_style),
Span::styled(label.to_string(), label_style),
])
}
fn split_path(path: &str) -> (String, String) {
if path.is_empty() {
return (String::new(), String::new());
}
if path.ends_with('/') {
return (path.to_string(), String::new());
}
match path.rfind('/') {
Some(idx) => (path[..=idx].to_string(), path[idx + 1..].to_string()),
None => (String::new(), path.to_string()),
}
}
fn expand_tilde(path: &str) -> String {
if path == "~" {
return std::env::var("HOME").unwrap_or_default();
}
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_default();
return format!("{}/{}", home, rest);
}
path.to_string()
}
fn read_dir_filtered(path: &str, limit: usize) -> Vec<PathEntry> {
let (dir, prefix) = split_path(path);
let lookup = if dir.is_empty() {
".".to_string()
} else {
expand_tilde(&dir)
};
let Ok(read) = std::fs::read_dir(&lookup) else {
return Vec::new();
};
let show_hidden = prefix.starts_with('.');
let mut out: Vec<PathEntry> = Vec::new();
for entry in read.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
if !name.starts_with(&prefix) {
continue;
}
let is_dir = entry.file_type().ok().map(|t| t.is_dir()).unwrap_or(false);
out.push(PathEntry { name, is_dir });
}
out.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
out.truncate(limit);
out
}
fn longest_common_prefix(strs: &[&str]) -> String {
if strs.is_empty() {
return String::new();
}
let mut prefix: Vec<char> = strs[0].chars().collect();
for s in &strs[1..] {
let common_len = prefix
.iter()
.zip(s.chars())
.take_while(|(a, b)| **a == *b)
.count();
prefix.truncate(common_len);
if prefix.is_empty() {
break;
}
}
prefix.into_iter().collect()
}
fn input_line(value: &str, focused: bool, width: u16, theme: &Theme) -> Line<'static> {
let bg = if focused {
theme.selection_bg
} else {
theme.bg
};
let fg = if value.is_empty() {
theme.text_muted
} else {
theme.text
};
let cursor = if focused { "│" } else { "" };
let content = format!(" {}{} ", value, cursor);
let field_width = width.saturating_sub(3) as usize;
let padded = if content.chars().count() < field_width {
let mut s = content;
while s.chars().count() < field_width {
s.push(' ');
}
s
} else {
content
};
Line::from(vec![
Span::styled(" ", Style::default().bg(theme.panel_alt)),
Span::styled(padded, Style::default().fg(fg).bg(bg)),
])
}
fn section_header(text: &str, theme: &Theme) -> Line<'static> {
Line::from(vec![
Span::styled(" ", Style::default().bg(theme.panel_alt)),
Span::styled(
text.to_string(),
Style::default()
.fg(theme.text_muted)
.bg(theme.panel_alt)
.add_modifier(Modifier::BOLD),
),
])
}
fn checkbox_line(label: &str, checked: bool, focused: bool, theme: &Theme) -> Line<'static> {
let body_bg = theme.panel_alt;
let marker = if focused { "▸" } else { " " };
let box_glyph = if checked { "[x]" } else { "[ ]" };
let label_style = if focused {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else if checked {
Style::default().fg(theme.text).bg(body_bg)
} else {
Style::default().fg(theme.text_muted).bg(body_bg)
};
let box_style = if checked {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(body_bg)
};
Line::from(vec![
Span::styled(format!(" {} ", marker), label_style),
Span::styled(box_glyph.to_string(), box_style),
Span::styled(" ", Style::default().bg(body_bg)),
Span::styled(label.to_string(), label_style),
])
}
fn session_radio_line(mode: ClaudeSessionMode, focused: bool, theme: &Theme) -> Line<'static> {
let body_bg = theme.panel_alt;
let marker = if focused { "▸" } else { " " };
let marker_style = if focused {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(body_bg)
};
let label_style = if focused {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(body_bg)
};
let mut spans: Vec<Span<'static>> = vec![
Span::styled(format!(" {} ", marker), marker_style),
Span::styled("Session ", label_style),
];
for option in [
ClaudeSessionMode::New,
ClaudeSessionMode::Continue,
ClaudeSessionMode::Resume,
] {
let selected = option == mode;
let (dot, val_style) = if selected {
let style = if focused {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.text)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
};
("(•)", style)
} else {
("( )", Style::default().fg(theme.text_muted).bg(body_bg))
};
spans.push(Span::styled(format!(" {} ", dot), val_style));
spans.push(Span::styled(option.label().to_string(), val_style));
spans.push(Span::styled(" ", Style::default().bg(body_bg)));
}
Line::from(spans)
}
fn agent_line(selected: usize, focused: bool, theme: &Theme) -> Line<'static> {
let body_bg = theme.panel_alt;
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(" ", Style::default().bg(body_bg)));
for (i, agent) in AGENTS.iter().enumerate() {
let style = if i == selected && focused {
Style::default()
.fg(theme.bg)
.bg(theme.accent)
.add_modifier(Modifier::BOLD)
} else if i == selected {
Style::default()
.fg(theme.accent)
.bg(body_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted).bg(body_bg)
};
spans.push(Span::styled(format!(" {} ", agent), style));
spans.push(Span::styled(" ", Style::default().bg(body_bg)));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn modal_for_field_tests() -> NewSessionModal {
let mut m = NewSessionModal::new(Vec::new());
m.path = "/_bosun_unit_test_nonexistent_/".into();
m
}
#[test]
fn tab_cycles_fields_for_claude() {
let mut m = modal_for_field_tests();
assert_eq!(m.agent(), "claude");
assert_eq!(m.field, Field::Name);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Path);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Agent);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Args);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::ClaudeSession);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::ClaudeSkipPerm);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Name);
}
#[test]
fn tab_cycles_fields_for_codex() {
let mut m = modal_for_field_tests();
m.agent_idx = 1;
assert_eq!(m.agent(), "codex");
m.handle(key(KeyCode::Tab)); m.handle(key(KeyCode::Tab)); m.handle(key(KeyCode::Tab)); m.handle(key(KeyCode::Tab)); assert_eq!(m.field, Field::CodexYolo);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Name);
}
#[test]
fn tab_cycles_fields_for_terminal() {
let mut m = modal_for_field_tests();
m.agent_idx = 2;
assert_eq!(m.agent(), "terminal");
m.handle(key(KeyCode::Tab)); m.handle(key(KeyCode::Tab)); m.handle(key(KeyCode::Tab)); assert_eq!(m.field, Field::Args);
m.handle(key(KeyCode::Tab));
assert_eq!(m.field, Field::Name);
}
#[test]
fn space_toggles_skip_permissions_when_focused() {
let mut m = NewSessionModal::new(Vec::new());
m.field = Field::ClaudeSkipPerm;
assert!(!m.claude.skip_permissions);
m.handle(key(KeyCode::Char(' ')));
assert!(m.claude.skip_permissions);
m.handle(key(KeyCode::Char(' ')));
assert!(!m.claude.skip_permissions);
}
#[test]
fn left_right_cycles_claude_session_mode() {
let mut m = NewSessionModal::new(Vec::new());
m.field = Field::ClaudeSession;
assert_eq!(m.claude.session_mode, ClaudeSessionMode::New);
m.handle(key(KeyCode::Right));
assert_eq!(m.claude.session_mode, ClaudeSessionMode::Continue);
m.handle(key(KeyCode::Right));
assert_eq!(m.claude.session_mode, ClaudeSessionMode::Resume);
m.handle(key(KeyCode::Right));
assert_eq!(m.claude.session_mode, ClaudeSessionMode::New);
m.handle(key(KeyCode::Left));
assert_eq!(m.claude.session_mode, ClaudeSessionMode::Resume);
}
#[test]
fn space_toggles_codex_yolo_when_focused() {
let mut m = NewSessionModal::new(Vec::new());
m.agent_idx = 1;
m.field = Field::CodexYolo;
assert!(!m.codex.yolo);
m.handle(key(KeyCode::Char(' ')));
assert!(m.codex.yolo);
}
#[test]
fn submit_spec_carries_claude_options() {
let mut m = NewSessionModal::new(Vec::new());
for c in "test".chars() {
m.handle(key(KeyCode::Char(c)));
}
m.claude.skip_permissions = true;
m.claude.session_mode = ClaudeSessionMode::Continue;
let r = m.handle(key(KeyCode::Enter));
match r {
ModalResult::Close(Some(Command::CreateSession(spec))) => {
assert!(spec.options.claude.skip_permissions);
assert_eq!(
spec.options.claude.session_mode,
ClaudeSessionMode::Continue
);
}
_ => panic!("expected CreateSession"),
}
}
#[test]
fn typing_fills_focused_field() {
let mut m = modal_for_field_tests();
for c in "api".chars() {
m.handle(key(KeyCode::Char(c)));
}
assert_eq!(m.name, "api");
m.handle(key(KeyCode::Tab));
m.handle(key(KeyCode::Backspace));
assert_eq!(m.name, "api");
}
#[test]
fn left_right_on_agent_field_cycles_selection() {
let mut m = NewSessionModal::new(Vec::new());
m.field = Field::Agent;
assert_eq!(m.agent(), "claude");
m.handle(key(KeyCode::Right));
assert_eq!(m.agent(), "codex");
m.handle(key(KeyCode::Right));
assert_eq!(m.agent(), "terminal");
m.handle(key(KeyCode::Right));
assert_eq!(m.agent(), "claude");
m.handle(key(KeyCode::Left));
assert_eq!(m.agent(), "terminal");
}
#[test]
fn enter_with_empty_name_shows_error() {
let mut m = NewSessionModal::new(Vec::new());
let r = m.handle(key(KeyCode::Enter));
assert!(matches!(r, ModalResult::Consumed));
assert!(m.error.is_some());
}
#[test]
fn enter_with_valid_data_closes_with_command() {
let mut m = NewSessionModal::new(Vec::new());
for c in "work".chars() {
m.handle(key(KeyCode::Char(c)));
}
let r = m.handle(key(KeyCode::Enter));
match r {
ModalResult::Close(Some(Command::CreateSession(spec))) => {
assert_eq!(spec.name, "work");
assert_eq!(spec.agent, "claude");
}
_ => panic!("expected Close with CreateSession"),
}
}
#[test]
fn bosun_prefix_is_stripped_from_name_on_submit() {
let mut m = NewSessionModal::new(Vec::new());
for c in "bosun-work".chars() {
m.handle(key(KeyCode::Char(c)));
}
let r = m.handle(key(KeyCode::Enter));
match r {
ModalResult::Close(Some(Command::CreateSession(spec))) => {
assert_eq!(spec.name, "work");
}
_ => panic!("expected Close with CreateSession"),
}
}
#[test]
fn name_with_spaces_is_accepted() {
let mut m = NewSessionModal::new(Vec::new());
for c in "My Rocket Fox".chars() {
m.handle(key(KeyCode::Char(c)));
}
let r = m.handle(key(KeyCode::Enter));
match r {
ModalResult::Close(Some(Command::CreateSession(spec))) => {
assert_eq!(spec.name, "My Rocket Fox");
}
_ => panic!("expected CreateSession with 'My Rocket Fox'"),
}
}
#[test]
fn name_with_only_symbols_is_rejected() {
let mut m = NewSessionModal::new(Vec::new());
for c in "!!!".chars() {
m.handle(key(KeyCode::Char(c)));
}
let r = m.handle(key(KeyCode::Enter));
assert!(matches!(r, ModalResult::Consumed));
assert!(m.error.as_deref().unwrap().contains("letter"));
}
#[test]
fn esc_closes_without_command() {
let mut m = NewSessionModal::new(Vec::new());
let r = m.handle(key(KeyCode::Esc));
assert!(matches!(r, ModalResult::Close(None)));
}
#[test]
fn ctrl_r_pushes_recents_modal() {
let recent = Recent {
id: 1,
name: "work".into(),
path: "/srv".into(),
agent: "claude".into(),
args: String::new(),
claude: ClaudeOptions::default(),
codex: CodexOptions::default(),
last_used_at: 0,
use_count: 1,
};
let mut m = NewSessionModal::new(vec![recent]);
let k = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
let r = m.handle(k);
assert!(matches!(r, ModalResult::Push(_)));
}
#[test]
fn split_path_handles_absolute_and_relative() {
assert_eq!(
split_path("/tmp/user/proj"),
("/tmp/user/".to_string(), "proj".to_string())
);
assert_eq!(
split_path("/tmp/user/"),
("/tmp/user/".to_string(), "".to_string())
);
assert_eq!(split_path("proj"), ("".to_string(), "proj".to_string()));
assert_eq!(split_path(""), ("".to_string(), "".to_string()));
}
#[test]
fn longest_common_prefix_handles_unicode() {
assert_eq!(longest_common_prefix(&["abcd", "abce"]), "abc");
assert_eq!(longest_common_prefix(&["abc", "xyz"]), "");
assert_eq!(longest_common_prefix(&["same", "same"]), "same");
assert_eq!(longest_common_prefix(&[]), "");
assert_eq!(longest_common_prefix(&["日本語", "日本人"]), "日本");
}
#[test]
fn on_child_closed_fills_all_fields_from_spec() {
let mut m = NewSessionModal::new(Vec::new());
let spec = SessionSpec {
name: "api".into(),
path: "/srv/api".into(),
agent: "codex".into(),
args: "--verbose".into(),
options: SpecOptions {
claude: ClaudeOptions::default(),
codex: CodexOptions { yolo: true },
},
};
m.on_child_closed(ModalData::FillSessionSpec(spec));
assert_eq!(m.name, "api");
assert_eq!(m.path, "/srv/api");
assert_eq!(m.args, "--verbose");
assert_eq!(m.agent(), "codex");
assert!(m.codex.yolo);
assert_eq!(m.field, Field::Name);
}
}