use std::path::PathBuf;
use crate::domain::agent::{self, AgentKind, AgentModel, AgentSelectionMetadata, ReasoningLevel};
use crate::domain::input::{InputState, is_at_mention_boundary, is_at_mention_query_character};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptSuggestionItem {
pub badge: Option<String>,
pub detail: Option<String>,
pub label: String,
pub metadata: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptSuggestionList {
pub items: Vec<PromptSuggestionItem>,
pub selected_index: usize,
pub title: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PromptSuggestionSelection {
Command(&'static str),
Agent(AgentKind),
Model(AgentModel),
Reasoning(ReasoningLevel),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptAttachment {
pub attachment_number: usize,
pub local_image_path: PathBuf,
pub placeholder: String,
}
impl PromptAttachment {
#[must_use]
pub fn new(attachment_number: usize, local_image_path: PathBuf) -> Self {
Self {
attachment_number,
local_image_path,
placeholder: Self::placeholder_for(attachment_number),
}
}
#[must_use]
pub fn placeholder_for(attachment_number: usize) -> String {
format!("[Image #{attachment_number}]")
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptComposerSubmission {
pub attachments: Vec<PromptAttachment>,
pub text: String,
}
impl PromptComposerSubmission {
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.trim().is_empty() && self.attachments.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PromptAttachmentState {
pub attachments: Vec<PromptAttachment>,
pub next_attachment_number: usize,
}
impl PromptAttachmentState {
pub fn register_local_image(&mut self, local_image_path: PathBuf) -> String {
let attachment = PromptAttachment::new(self.next_attachment_number, local_image_path);
let placeholder = attachment.placeholder.clone();
self.attachments.push(attachment);
self.refresh_next_attachment_number();
placeholder
}
#[must_use]
pub fn attachment_for_placeholder(&self, placeholder: &str) -> Option<&PromptAttachment> {
self.attachments
.iter()
.find(|attachment| attachment.placeholder == placeholder)
}
pub fn refresh_next_attachment_number(&mut self) {
let mut next_attachment_number = 1;
while self
.attachments
.iter()
.any(|attachment| attachment.attachment_number == next_attachment_number)
{
next_attachment_number += 1;
}
self.next_attachment_number = next_attachment_number;
}
pub fn reset(&mut self) {
self.attachments.clear();
self.next_attachment_number = 1;
}
}
impl Default for PromptAttachmentState {
fn default() -> Self {
Self {
attachments: Vec::new(),
next_attachment_number: 1,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PromptHistoryState {
pub draft_text: Option<String>,
pub entries: Vec<String>,
pub selected_index: Option<usize>,
}
impl PromptHistoryState {
#[must_use]
pub fn new(entries: Vec<String>) -> Self {
Self {
draft_text: None,
entries,
selected_index: None,
}
}
pub fn reset_navigation(&mut self) {
self.draft_text = None;
self.selected_index = None;
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PromptSlashStage {
Agent,
Command,
Model,
Reasoning,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PromptSlashState {
pub available_agent_kinds: Vec<AgentKind>,
pub selected_agent: Option<AgentKind>,
pub selected_index: usize,
pub stage: PromptSlashStage,
}
impl PromptSlashState {
#[must_use]
pub fn with_available_agent_kinds(available_agent_kinds: Vec<AgentKind>) -> Self {
Self {
available_agent_kinds,
selected_agent: None,
selected_index: 0,
stage: PromptSlashStage::Command,
}
}
pub fn replace_available_agent_kinds(&mut self, available_agent_kinds: Vec<AgentKind>) {
self.available_agent_kinds = available_agent_kinds;
if self
.selected_agent
.is_some_and(|selected_agent| !self.available_agent_kinds.contains(&selected_agent))
{
self.selected_agent = None;
self.selected_index = 0;
if matches!(self.stage, PromptSlashStage::Model) {
self.stage = PromptSlashStage::Agent;
}
}
}
pub fn reset(&mut self) {
self.selected_agent = None;
self.selected_index = 0;
self.stage = PromptSlashStage::Command;
}
}
impl Default for PromptSlashState {
fn default() -> Self {
Self::with_available_agent_kinds(AgentKind::ALL.to_vec())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PromptComposerState {
pub attachment_state: PromptAttachmentState,
pub history_state: PromptHistoryState,
pub input: InputState,
pub slash_state: PromptSlashState,
}
impl PromptComposerState {
#[must_use]
pub fn new(available_agent_kinds: Vec<AgentKind>) -> Self {
Self::with_input_and_history(InputState::default(), available_agent_kinds, Vec::new())
}
#[must_use]
pub fn with_input_and_history(
input: InputState,
available_agent_kinds: Vec<AgentKind>,
history_entries: Vec<String>,
) -> Self {
Self {
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(history_entries),
input,
slash_state: PromptSlashState::with_available_agent_kinds(available_agent_kinds),
}
}
#[must_use]
pub fn is_slash_command(&self) -> bool {
self.input.text().starts_with('/')
}
#[must_use]
pub fn slash_suggestion_list(
&self,
session_agent_kind: AgentKind,
) -> Option<PromptSuggestionList> {
build_prompt_slash_suggestion_list(
self.input.text(),
&self.slash_state,
session_agent_kind,
true,
)
}
#[must_use]
pub fn selected_slash_action(
&self,
session_agent_kind: AgentKind,
) -> Option<PromptSuggestionSelection> {
resolve_prompt_slash_selection(
self.input.text(),
&self.slash_state,
session_agent_kind,
true,
)
}
pub fn insert_text(&mut self, text: &str) {
insert_prompt_text(
&mut self.input,
&mut self.history_state,
&mut self.slash_state,
text,
);
}
pub fn insert_char(&mut self, character: char) {
insert_prompt_character(
&mut self.input,
&mut self.history_state,
&mut self.slash_state,
character,
);
}
pub fn insert_local_image(&mut self, local_image_path: PathBuf) {
insert_prompt_local_image(
&mut self.attachment_state,
&mut self.history_state,
&mut self.input,
&mut self.slash_state,
local_image_path,
);
}
pub fn delete_range(&mut self, start: usize, end: usize) {
apply_prompt_delete_range(
&mut self.attachment_state,
&mut self.history_state,
&mut self.input,
&mut self.slash_state,
start,
end,
);
}
pub fn take_submission(&mut self) -> PromptComposerSubmission {
drain_prompt_submission(&mut self.attachment_state, &mut self.input)
}
}
impl Default for PromptComposerState {
fn default() -> Self {
Self::new(AgentKind::ALL.to_vec())
}
}
#[must_use]
pub fn prompt_slash_option_count(
input: &str,
stage: PromptSlashStage,
selected_agent: Option<AgentKind>,
available_agent_kinds: &[AgentKind],
session_agent_kind: AgentKind,
allow_apply_command: bool,
) -> usize {
build_prompt_slash_suggestion_list(
input,
&PromptSlashState {
available_agent_kinds: available_agent_kinds.to_vec(),
selected_agent,
selected_index: 0,
stage,
},
session_agent_kind,
allow_apply_command,
)
.map_or(0, |suggestion_list| suggestion_list.items.len())
}
#[must_use]
pub fn current_line_delete_range(input: &InputState) -> Option<(usize, usize)> {
let characters: Vec<char> = input.text().chars().collect();
if characters.is_empty() {
return None;
}
let cursor = input.cursor.min(characters.len());
let mut line_start = cursor;
while line_start > 0 && characters[line_start - 1] != '\n' {
line_start -= 1;
}
let mut line_end = cursor;
while line_end < characters.len() && characters[line_end] != '\n' {
line_end += 1;
}
let delete_range = if line_start > 0 {
(line_start - 1, line_end)
} else if line_end < characters.len() {
(line_start, line_end + 1)
} else {
(line_start, line_end)
};
if delete_range.0 == delete_range.1 {
return None;
}
Some(delete_range)
}
#[must_use]
pub fn expand_delete_range_to_image_tokens(text: &str, start: usize, end: usize) -> (usize, usize) {
let mut expanded_start = start;
let mut expanded_end = end;
for (token_start, token_end, _) in image_token_ranges(text) {
if token_start < expanded_end && expanded_start < token_end {
expanded_start = expanded_start.min(token_start);
expanded_end = expanded_end.max(token_end);
}
}
(expanded_start, expanded_end)
}
#[must_use]
pub fn image_token_ranges(text: &str) -> Vec<(usize, usize, String)> {
let characters = text.chars().collect::<Vec<_>>();
let mut ranges = Vec::new();
let mut index = 0;
while index < characters.len() {
if let Some(end_index) = image_token_end_index(&characters, index) {
let placeholder = characters[index..end_index].iter().collect::<String>();
ranges.push((index, end_index, placeholder));
index = end_index;
continue;
}
index += 1;
}
ranges
}
pub fn insert_prompt_text(
input: &mut InputState,
history_state: &mut PromptHistoryState,
slash_state: &mut PromptSlashState,
text: &str,
) {
input.insert_text(text);
history_state.reset_navigation();
slash_state.reset();
}
pub fn insert_prompt_character(
input: &mut InputState,
history_state: &mut PromptHistoryState,
slash_state: &mut PromptSlashState,
character: char,
) {
input.insert_char(character);
history_state.reset_navigation();
slash_state.reset();
}
pub fn insert_prompt_local_image(
attachment_state: &mut PromptAttachmentState,
history_state: &mut PromptHistoryState,
input: &mut InputState,
slash_state: &mut PromptSlashState,
local_image_path: PathBuf,
) {
let placeholder = attachment_state.register_local_image(local_image_path);
input.insert_text(&placeholder);
history_state.reset_navigation();
slash_state.reset();
}
pub fn apply_prompt_delete_range(
attachment_state: &mut PromptAttachmentState,
history_state: &mut PromptHistoryState,
input: &mut InputState,
slash_state: &mut PromptSlashState,
start: usize,
end: usize,
) {
let (delete_start, delete_end) = expand_delete_range_to_image_tokens(input.text(), start, end);
if delete_start >= delete_end {
return;
}
input.replace_range(delete_start, delete_end, "");
attachment_state
.attachments
.retain(|attachment| input.text().contains(&attachment.placeholder));
attachment_state.refresh_next_attachment_number();
history_state.reset_navigation();
slash_state.reset();
}
pub fn drain_prompt_submission(
attachment_state: &mut PromptAttachmentState,
input: &mut InputState,
) -> PromptComposerSubmission {
let text = input.take_text();
let mut attachments = attachment_state
.attachments
.iter()
.filter(|attachment| text.contains(&attachment.placeholder))
.cloned()
.collect::<Vec<_>>();
attachments.sort_by_key(|attachment| text.find(&attachment.placeholder).unwrap_or(usize::MAX));
attachment_state.reset();
PromptComposerSubmission { attachments, text }
}
#[must_use]
pub fn render_prompt_text_for_agent(text: &str) -> String {
let characters = text.chars().collect::<Vec<char>>();
let mut output = String::with_capacity(text.len());
let mut index = 0;
while let Some(&character) = characters.get(index) {
if character != '@'
|| !is_at_mention_boundary(characters.get(index.wrapping_sub(1)).copied())
|| index + 1 >= characters.len()
{
output.push(character);
index += 1;
continue;
}
let mut scan_index = index + 1;
while scan_index < characters.len() && is_at_mention_query_character(characters[scan_index])
{
scan_index += 1;
}
if scan_index == index + 1 {
output.push(character);
index += 1;
continue;
}
output.push('"');
output.extend(characters[index + 1..scan_index].iter());
output.push('"');
index = scan_index;
}
output
}
#[must_use]
pub fn build_prompt_slash_suggestion_list(
input: &str,
slash_state: &PromptSlashState,
session_agent_kind: AgentKind,
allow_apply_command: bool,
) -> Option<PromptSuggestionList> {
build_slash_suggestion_list(
input,
&slash_state.available_agent_kinds,
slash_state.stage,
slash_state.selected_agent,
session_agent_kind,
slash_state.selected_index,
allow_apply_command,
)
}
#[must_use]
pub fn resolve_prompt_slash_selection(
input: &str,
slash_state: &PromptSlashState,
session_agent_kind: AgentKind,
allow_apply_command: bool,
) -> Option<PromptSuggestionSelection> {
selected_slash_action(
input,
&slash_state.available_agent_kinds,
slash_state.stage,
slash_state.selected_agent,
slash_state.selected_index,
session_agent_kind,
allow_apply_command,
)
}
fn build_slash_suggestion_list(
input: &str,
available_agent_kinds: &[AgentKind],
stage: PromptSlashStage,
selected_agent: Option<AgentKind>,
session_agent_kind: AgentKind,
selected_index: usize,
allow_apply_command: bool,
) -> Option<PromptSuggestionList> {
if !input.starts_with('/') {
return None;
}
let (title, items): (&str, Vec<PromptSuggestionItem>) = match stage {
PromptSlashStage::Command => {
let commands = prompt_slash_commands(input, allow_apply_command)
.into_iter()
.map(|command| PromptSuggestionItem {
badge: None,
detail: Some(command_description(command).to_string()),
label: command.to_string(),
metadata: None,
})
.collect::<Vec<_>>();
("Slash Command (j/k move, Enter select)", commands)
}
PromptSlashStage::Agent => (
"/model Agent (j/k move, Enter select)",
available_agent_kinds
.iter()
.map(|agent_kind| PromptSuggestionItem {
badge: None,
detail: Some(agent_kind.description().to_string()),
label: agent_kind.name().to_string(),
metadata: None,
})
.collect(),
),
PromptSlashStage::Model => {
let selected_agent_kind = resolve_model_stage_agent(
session_agent_kind,
available_agent_kinds,
selected_agent,
)?;
let models = selected_agent_kind
.models()
.iter()
.map(|model| PromptSuggestionItem {
badge: None,
detail: Some(model.description().to_string()),
label: model.name().to_string(),
metadata: None,
})
.collect::<Vec<_>>();
("/model Model (j/k move, Enter select)", models)
}
PromptSlashStage::Reasoning => (
"/reasoning Level (j/k move, Enter select)",
reasoning_suggestion_items(),
),
};
if items.is_empty() {
return None;
}
let max_index = items.len().saturating_sub(1);
Some(PromptSuggestionList {
items,
selected_index: selected_index.min(max_index),
title: title.to_string(),
})
}
fn selected_slash_action(
input: &str,
available_agent_kinds: &[AgentKind],
stage: PromptSlashStage,
selected_agent: Option<AgentKind>,
selected_index: usize,
session_agent_kind: AgentKind,
allow_apply_command: bool,
) -> Option<PromptSuggestionSelection> {
match stage {
PromptSlashStage::Command => {
let commands = prompt_slash_commands(input, allow_apply_command);
let selected_command = commands
.get(clamp_selected_index(selected_index, commands.len()))
.copied()?;
Some(PromptSuggestionSelection::Command(selected_command))
}
PromptSlashStage::Agent => available_agent_kinds
.get(clamp_selected_index(
selected_index,
available_agent_kinds.len(),
))
.copied()
.map(PromptSuggestionSelection::Agent),
PromptSlashStage::Model => {
let selected_agent_kind = resolve_model_stage_agent(
session_agent_kind,
available_agent_kinds,
selected_agent,
)?;
let models = selected_agent_kind.models();
let selected_model = models
.get(clamp_selected_index(selected_index, models.len()))
.copied()?;
Some(PromptSuggestionSelection::Model(selected_model))
}
PromptSlashStage::Reasoning => {
let options = reasoning_options();
let selected_reasoning = options
.get(clamp_selected_index(selected_index, options.len()))
.copied()?;
Some(PromptSuggestionSelection::Reasoning(selected_reasoning))
}
}
}
fn clamp_selected_index(selected_index: usize, option_count: usize) -> usize {
selected_index.min(option_count.saturating_sub(1))
}
fn resolve_model_stage_agent(
session_agent_kind: AgentKind,
available_agent_kinds: &[AgentKind],
selected_agent: Option<AgentKind>,
) -> Option<AgentKind> {
selected_agent.or_else(|| {
agent::resolve_prompt_model_agent_kind(session_agent_kind, available_agent_kinds)
})
}
fn command_description(command: &str) -> &'static str {
match command {
"/apply" => "Verify focused-review suggestions, then apply the correct ones.",
"/model" => "Choose an agent and model for this session.",
"/qe:check" => "Send the quality-enforcement check prompt.",
"/reasoning" => "Override the reasoning level for this session.",
"/stats" => "Check session stats.",
_ => "Prompt slash command.",
}
}
fn prompt_slash_commands(input: &str, allow_apply_command: bool) -> Vec<&'static str> {
let lowered = input.to_lowercase();
let mut commands = vec!["/apply", "/model", "/qe:check", "/reasoning", "/stats"];
if !allow_apply_command {
commands.retain(|command| *command != "/apply");
}
commands.retain(|command| command.starts_with(&lowered));
commands
}
fn reasoning_options() -> Vec<ReasoningLevel> {
ReasoningLevel::ALL.to_vec()
}
fn reasoning_suggestion_items() -> Vec<PromptSuggestionItem> {
ReasoningLevel::ALL
.into_iter()
.map(|reasoning_level| PromptSuggestionItem {
badge: None,
detail: Some(reasoning_level.description().to_string()),
label: reasoning_level.name().to_string(),
metadata: None,
})
.collect()
}
fn image_token_end_index(characters: &[char], start_index: usize) -> Option<usize> {
let token_body = characters.get(start_index..)?;
if token_body.len() < "[Image #1]".chars().count() || token_body.first() != Some(&'[') {
return None;
}
let image_prefix = ['[', 'I', 'm', 'a', 'g', 'e', ' ', '#'];
if token_body.get(..image_prefix.len())? != image_prefix {
return None;
}
let mut scan_index = start_index + image_prefix.len();
let mut saw_digit = false;
while let Some(character) = characters.get(scan_index) {
if character.is_ascii_digit() {
saw_digit = true;
scan_index += 1;
continue;
}
if *character == ']' && saw_digit {
return Some(scan_index + 1);
}
return None;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prompt_attachment_state_registers_images_in_placeholder_order() {
let mut attachment_state = PromptAttachmentState::default();
let first_placeholder =
attachment_state.register_local_image(PathBuf::from("/tmp/first-image.png"));
let second_placeholder =
attachment_state.register_local_image(PathBuf::from("/tmp/second-image.png"));
assert_eq!(first_placeholder, "[Image #1]");
assert_eq!(second_placeholder, "[Image #2]");
assert_eq!(attachment_state.attachments.len(), 2);
assert_eq!(
attachment_state.attachment_for_placeholder("[Image #2]"),
Some(&PromptAttachment {
attachment_number: 2,
local_image_path: PathBuf::from("/tmp/second-image.png"),
placeholder: "[Image #2]".to_string(),
})
);
}
#[test]
fn test_prompt_attachment_state_reset_clears_attachments_and_restarts_numbering() {
let mut attachment_state = PromptAttachmentState::default();
let _ = attachment_state.register_local_image(PathBuf::from("/tmp/first-image.png"));
attachment_state.reset();
let placeholder =
attachment_state.register_local_image(PathBuf::from("/tmp/second-image.png"));
assert_eq!(attachment_state.attachments.len(), 1);
assert_eq!(attachment_state.next_attachment_number, 2);
assert_eq!(placeholder, "[Image #1]");
}
#[test]
fn test_prompt_attachment_state_refresh_next_attachment_number_reuses_gaps() {
let mut attachment_state = PromptAttachmentState {
attachments: vec![
PromptAttachment::new(1, PathBuf::from("/tmp/first-image.png")),
PromptAttachment::new(3, PathBuf::from("/tmp/third-image.png")),
],
next_attachment_number: 99,
};
attachment_state.refresh_next_attachment_number();
assert_eq!(attachment_state.next_attachment_number, 2);
}
#[test]
fn test_prompt_slash_state_replace_available_agent_kinds_clears_unavailable_selection() {
let mut slash_state =
PromptSlashState::with_available_agent_kinds(vec![AgentKind::Claude, AgentKind::Codex]);
slash_state.selected_agent = Some(AgentKind::Claude);
slash_state.selected_index = 2;
slash_state.stage = PromptSlashStage::Model;
slash_state.replace_available_agent_kinds(vec![AgentKind::Codex]);
assert_eq!(slash_state.available_agent_kinds, vec![AgentKind::Codex]);
assert_eq!(slash_state.selected_agent, None);
assert_eq!(slash_state.selected_index, 0);
assert_eq!(slash_state.stage, PromptSlashStage::Agent);
}
#[test]
fn test_slash_suggestion_list_for_command_stage_has_description() {
let composer = PromptComposerState::with_input_and_history(
InputState::with_text("/m".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
assert_eq!(
suggestion_list,
PromptSuggestionList {
items: vec![PromptSuggestionItem {
badge: None,
detail: Some("Choose an agent and model for this session.".to_string()),
label: "/model".to_string(),
metadata: None,
}],
selected_index: 0,
title: "Slash Command (j/k move, Enter select)".to_string(),
}
);
}
#[test]
fn test_slash_suggestion_list_for_agent_stage_uses_available_agent_kinds() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/model".to_string()),
vec![AgentKind::Claude],
Vec::new(),
);
composer.slash_state.stage = PromptSlashStage::Agent;
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
assert_eq!(suggestion_list.items.len(), 1);
assert_eq!(suggestion_list.items[0].label, "claude");
}
#[test]
fn test_selected_slash_action_returns_selected_model() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/model".to_string()),
vec![AgentKind::Claude],
Vec::new(),
);
composer.slash_state.stage = PromptSlashStage::Model;
composer.slash_state.selected_agent = Some(AgentKind::Claude);
let selection = composer.selected_slash_action(AgentKind::Codex);
assert_eq!(
selection,
Some(PromptSuggestionSelection::Model(AgentModel::ClaudeOpus48))
);
}
#[test]
fn test_selected_slash_action_returns_selected_reasoning_level() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/reasoning".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
composer.slash_state.stage = PromptSlashStage::Reasoning;
composer.slash_state.selected_index = 2;
let selection = composer.selected_slash_action(AgentKind::Codex);
assert_eq!(
selection,
Some(PromptSuggestionSelection::Reasoning(ReasoningLevel::High))
);
}
#[test]
fn test_selected_slash_action_clamps_stale_command_index() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/s".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
composer.slash_state.selected_index = 9;
let selection = composer.selected_slash_action(AgentKind::Codex);
assert_eq!(
selection,
Some(PromptSuggestionSelection::Command("/stats"))
);
}
#[test]
fn test_model_stage_suggestion_list_prefers_available_session_agent_when_unset() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/model".to_string()),
vec![AgentKind::Gemini, AgentKind::Codex],
Vec::new(),
);
composer.slash_state.stage = PromptSlashStage::Model;
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
let labels = suggestion_list
.items
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert_eq!(
labels,
vec![
"gpt-5.5".to_string(),
"gpt-5.4-mini".to_string(),
"gpt-5.3-codex-spark".to_string(),
]
);
}
#[test]
fn test_reasoning_stage_suggestion_list_omits_default_option() {
let mut composer = PromptComposerState::with_input_and_history(
InputState::with_text("/reasoning".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
composer.slash_state.stage = PromptSlashStage::Reasoning;
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
let labels = suggestion_list
.items
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert_eq!(labels, vec!["low", "medium", "high", "xhigh"]);
}
#[test]
fn test_prompt_composer_delete_range_removes_whole_image_token() {
let mut composer = PromptComposerState::new(AgentKind::ALL.to_vec());
composer.insert_text("Review [Image #1] now");
composer.attachment_state.attachments =
vec![PromptAttachment::new(1, PathBuf::from("/tmp/image.png"))];
composer.attachment_state.next_attachment_number = 2;
composer.delete_range(10, 11);
assert_eq!(composer.input.text(), "Review now");
assert!(composer.attachment_state.attachments.is_empty());
assert_eq!(composer.attachment_state.next_attachment_number, 1);
}
#[test]
fn test_take_submission_filters_deleted_attachment_placeholders() {
let mut composer = PromptComposerState::new(AgentKind::ALL.to_vec());
composer.insert_text("One [Image #1] two [Image #2]");
composer.attachment_state.attachments = vec![
PromptAttachment::new(1, PathBuf::from("/tmp/one.png")),
PromptAttachment::new(2, PathBuf::from("/tmp/two.png")),
];
composer.delete_range(4, 15);
let submission = composer.take_submission();
assert_eq!(submission.text, "One two [Image #2]");
assert_eq!(submission.attachments.len(), 1);
assert_eq!(submission.attachments[0].placeholder, "[Image #2]");
}
#[test]
fn test_drain_prompt_submission_keeps_raw_at_lookup_text() {
let mut composer = PromptComposerState::new(AgentKind::ALL.to_vec());
composer.input =
InputState::with_text("Check @src/main.rs and @docs/guide.md before @".to_string());
let submission = composer.take_submission();
assert_eq!(
submission.text,
"Check @src/main.rs and @docs/guide.md before @"
);
assert_eq!(submission.attachments.len(), 0);
}
#[test]
fn test_drain_prompt_submission_preserves_email_lookalikes() {
let mut composer = PromptComposerState::new(AgentKind::ALL.to_vec());
composer.input = InputState::with_text("Notify user@example.com and @!".to_string());
let submission = composer.take_submission();
assert_eq!(submission.text, "Notify user@example.com and @!");
assert!(submission.attachments.is_empty());
}
#[test]
fn test_render_prompt_text_for_agent_quotes_user_at_lookups() {
let prompt_text = "Check @src/main.rs and (@docs/guide.md)";
let rendered_text = render_prompt_text_for_agent(prompt_text);
assert_eq!(
rendered_text,
"Check \"src/main.rs\" and (\"docs/guide.md\")"
);
}
#[test]
fn test_render_prompt_text_for_agent_preserves_literal_looked_up_paths() {
let prompt_text = "Check looked/up/README.md, @looked/up/Cargo.toml, \
\"looked/up/src/main.rs\", or `looked/up/lib.rs`";
let rendered_text = render_prompt_text_for_agent(prompt_text);
assert_eq!(
rendered_text,
"Check looked/up/README.md, \"looked/up/Cargo.toml\", \"looked/up/src/main.rs\", or \
`looked/up/lib.rs`"
);
}
#[test]
fn test_render_prompt_text_for_agent_preserves_non_lookup_at_tokens() {
let prompt_text = "Notify user@example.com and leave @ alone";
let rendered_text = render_prompt_text_for_agent(prompt_text);
assert_eq!(rendered_text, "Notify user@example.com and leave @ alone");
}
#[test]
fn test_current_line_delete_range_returns_first_line_range() {
let mut input = InputState::with_text("first line\nsecond line".to_string());
input.cursor = 0;
let delete_range = current_line_delete_range(&input);
assert_eq!(delete_range, Some((0, 11)));
}
#[test]
fn test_slash_suggestion_list_includes_apply_command() {
let composer = PromptComposerState::with_input_and_history(
InputState::with_text("/a".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
assert_eq!(suggestion_list.items.len(), 1);
assert_eq!(suggestion_list.items[0].label, "/apply");
assert_eq!(
suggestion_list.items[0].detail.as_deref(),
Some("Verify focused-review suggestions, then apply the correct ones.")
);
}
#[test]
fn test_prompt_slash_command_list_omits_apply_when_disabled() {
let slash_state = PromptSlashState::default();
let suggestion_list =
build_prompt_slash_suggestion_list("/", &slash_state, AgentKind::Codex, false)
.expect("expected suggestion list");
let labels = suggestion_list
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>();
assert_eq!(labels, vec!["/model", "/qe:check", "/reasoning", "/stats"]);
assert_eq!(suggestion_list.selected_index, 0);
}
#[test]
fn test_slash_suggestion_list_includes_qe_check_command() {
let composer = PromptComposerState::with_input_and_history(
InputState::with_text("/q".to_string()),
AgentKind::ALL.to_vec(),
Vec::new(),
);
let suggestion_list = composer
.slash_suggestion_list(AgentKind::Codex)
.expect("expected suggestion list");
assert_eq!(suggestion_list.items.len(), 1);
assert_eq!(suggestion_list.items[0].label, "/qe:check");
assert_eq!(
suggestion_list.items[0].detail.as_deref(),
Some("Send the quality-enforcement check prompt.")
);
}
#[test]
fn test_prompt_slash_selection_uses_filtered_command_indexes() {
let slash_state = PromptSlashState {
selected_index: 0,
..PromptSlashState::default()
};
let selection = resolve_prompt_slash_selection("/", &slash_state, AgentKind::Codex, false);
assert_eq!(
selection,
Some(PromptSuggestionSelection::Command("/model"))
);
}
}