use std::path::PathBuf;
use ag_agent::agent;
use tracing::warn;
use crate::app::{App, ReviewCacheEntry, diff_content_hash};
use crate::domain::agent::{AgentKind, ReasoningLevel};
use crate::domain::review;
use crate::domain::session::{SessionId, Status};
use crate::domain::transcript_notice::TranscriptNotice;
use crate::domain::turn_prompt::{TurnPrompt, TurnPromptAttachment, TurnPromptTextSource};
use crate::infra::clipboard_image;
use crate::ui::state::app_mode::AppMode;
use crate::ui::state::prompt::{
PromptSlashStage, PromptSuggestionSelection, drain_prompt_submission,
insert_prompt_local_image, resolve_prompt_slash_selection,
};
const QE_CHECK_PROMPT: &str = include_str!("template/qe_check_prompt.md");
const APPLY_REVIEW_PROMPT_TEMPLATE: &str = include_str!("template/apply_review_prompt.md");
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PromptIntentContext {
pub(crate) input_mode: PromptIntentInputMode,
pub(crate) scroll_offset: Option<u16>,
pub(crate) session_id: SessionId,
pub(crate) session_index: usize,
pub(crate) session_mode: PromptIntentSessionMode,
}
impl PromptIntentContext {
fn can_delete_on_cancel(&self) -> bool {
self.session_mode == PromptIntentSessionMode::NewDeletable
}
fn is_draft_session(&self) -> bool {
self.session_mode == PromptIntentSessionMode::NewDraft
}
fn is_new_session(&self) -> bool {
self.session_mode != PromptIntentSessionMode::Existing
}
fn is_slash_command(&self) -> bool {
self.input_mode == PromptIntentInputMode::SlashCommand
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PromptIntentInputMode {
SlashCommand,
Text,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PromptIntentSessionMode {
Existing,
NewDeletable,
NewDraft,
NewRegular,
}
impl App {
pub(crate) async fn handle_prompt_submit_intent(&mut self, context: &PromptIntentContext) {
if context.is_slash_command() {
self.handle_prompt_slash_submit_intent(context).await;
return;
}
let prompt = self.take_submitted_turn_prompt();
if prompt.is_empty() {
return;
}
self.submit_turn_prompt_for_context(context, prompt).await;
self.restore_prompt_session_view(context);
}
pub(crate) async fn handle_prompt_slash_submit_intent(
&mut self,
context: &PromptIntentContext,
) {
let session_agent_kind = self
.session_at(context.session_index)
.map_or(AgentKind::Codex, |session| session.agent.kind());
let selection = match &self.mode {
AppMode::Prompt {
input, slash_state, ..
} => resolve_prompt_slash_selection(
input.text(),
slash_state,
session_agent_kind,
self.prompt_apply_command_is_available_for_session(&context.session_id),
),
_ => None,
};
match selection {
Some(PromptSuggestionSelection::Command("/apply")) => {
if self.handle_apply_prompt_command(context).await {
self.reset_prompt_slash_input();
} else {
self.reset_prompt_slash_state();
}
}
Some(PromptSuggestionSelection::Command("/qe:check")) => {
self.reset_prompt_slash_input();
self.handle_qe_check_prompt_command(context).await;
}
Some(PromptSuggestionSelection::Command("/reasoning")) => {
let selected_reasoning_level = self
.session_at(context.session_index)
.map_or(self.settings.reasoning_level, |session| {
session.effective_reasoning_level(self.settings.reasoning_level)
});
let selected_index = ReasoningLevel::ALL
.iter()
.position(|level| *level == selected_reasoning_level)
.unwrap_or(0);
if let AppMode::Prompt { slash_state, .. } = &mut self.mode {
slash_state.stage = PromptSlashStage::Reasoning;
slash_state.selected_agent = None;
slash_state.selected_index = selected_index;
}
}
Some(PromptSuggestionSelection::Command(_)) => {
if let AppMode::Prompt { slash_state, .. } = &mut self.mode {
slash_state.stage = PromptSlashStage::Agent;
slash_state.selected_agent = None;
slash_state.selected_index = 0;
}
}
Some(PromptSuggestionSelection::Agent(selected_agent)) => {
if let AppMode::Prompt { slash_state, .. } = &mut self.mode {
slash_state.selected_agent = Some(selected_agent);
slash_state.stage = PromptSlashStage::Model;
slash_state.selected_index = 0;
}
}
Some(PromptSuggestionSelection::Model(selected_agent)) => {
self.reset_prompt_slash_input();
self.update_prompt_session_model(context, selected_agent)
.await;
}
Some(PromptSuggestionSelection::Reasoning(reasoning_level)) => {
self.reset_prompt_slash_input();
self.update_prompt_session_reasoning_level(context, reasoning_level)
.await;
}
None => {}
}
}
pub(crate) async fn handle_prompt_image_paste_intent(&mut self, context: &PromptIntentContext) {
let attachment_number = match &self.mode {
AppMode::Prompt {
attachment_state, ..
} => attachment_state.next_attachment_number,
_ => return,
};
match self
.services
.clipboard_image_client()
.persist_clipboard_image(context.session_id.as_str().to_string(), attachment_number)
.await
{
Ok(persisted_image) => {
self.insert_pasted_image_placeholder(persisted_image.local_image_path);
}
Err(error) => {
self.append_prompt_status_line(
context.session_id.as_str(),
TranscriptNotice::PasteImageError,
&clipboard_image::normalize_clipboard_image_error(&error),
)
.await;
}
}
}
pub(crate) fn insert_pasted_image_placeholder(&mut self, local_image_path: PathBuf) {
if let AppMode::Prompt {
at_mention_state,
attachment_state,
history_state,
input,
slash_state,
..
} = &mut self.mode
{
insert_prompt_local_image(
attachment_state,
history_state,
input,
slash_state,
local_image_path,
);
*at_mention_state = None;
}
}
pub(crate) async fn handle_prompt_cancel_intent(&mut self, context: &PromptIntentContext) {
if context.is_slash_command() {
self.reset_prompt_slash_input();
return;
}
self.cleanup_prompt_attachment_state().await;
if context.can_delete_on_cancel() {
self.delete_selected_session_deferred_cleanup().await;
self.mode = AppMode::List;
return;
}
self.mode = AppMode::View {
scroll_offset: context.scroll_offset,
session_id: context.session_id.clone(),
};
}
pub(crate) fn prompt_apply_command_is_available(&self) -> bool {
let Some(session_id) = self.prompt_session_id() else {
return false;
};
self.prompt_apply_command_is_available_for_session(&session_id)
}
pub(crate) fn take_submitted_turn_prompt(&mut self) -> TurnPrompt {
match &mut self.mode {
AppMode::Prompt {
attachment_state,
input,
..
} => {
let submission = drain_prompt_submission(attachment_state, input);
let attachments = submission
.attachments
.into_iter()
.map(|attachment| TurnPromptAttachment {
local_image_path: attachment.local_image_path,
placeholder: attachment.placeholder,
})
.collect();
TurnPrompt {
attachments,
text: submission.text,
text_source: TurnPromptTextSource::UserPrompt,
}
}
_ => TurnPrompt::from_text(String::new()),
}
}
async fn submit_turn_prompt_for_context(
&mut self,
context: &PromptIntentContext,
prompt: TurnPrompt,
) {
if context.is_draft_session() {
if let Err(error) = self.stage_draft_message(&context.session_id, prompt).await {
self.append_output_for_session(
&context.session_id,
&TranscriptNotice::Error.format(error),
)
.await;
}
} else if context.is_new_session() {
if let Err(error) = self.start_session(&context.session_id, prompt).await {
self.append_output_for_session(
&context.session_id,
&TranscriptNotice::Error.format(error),
)
.await;
}
} else if self.session_is_in_progress(&context.session_id) {
if let Err(error) = self.enqueue_message(&context.session_id, prompt) {
self.append_output_for_session(
&context.session_id,
&TranscriptNotice::QueueError.format(error),
)
.await;
}
} else {
self.reply(&context.session_id, prompt).await;
}
}
fn restore_prompt_session_view(&mut self, context: &PromptIntentContext) {
self.mode = AppMode::View {
scroll_offset: None,
session_id: context.session_id.clone(),
};
}
fn session_is_in_progress(&self, session_id: &str) -> bool {
self.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.is_some_and(|session| session.status == Status::InProgress)
}
fn reset_prompt_slash_input(&mut self) {
if let AppMode::Prompt {
input, slash_state, ..
} = &mut self.mode
{
input.take_text();
slash_state.reset();
}
}
fn reset_prompt_slash_state(&mut self) {
if let AppMode::Prompt { slash_state, .. } = &mut self.mode {
slash_state.reset();
}
}
async fn update_prompt_session_model(
&mut self,
context: &PromptIntentContext,
selected_agent: crate::domain::agent::AgentSelection,
) {
if let Err(error) = self
.set_session_model(&context.session_id, selected_agent)
.await
{
warn!(
session_id = %context.session_id,
agent = %selected_agent.kind(),
model = %selected_agent.model().as_str(),
error = %error,
"failed to switch session model from prompt slash command"
);
}
}
async fn update_prompt_session_reasoning_level(
&mut self,
context: &PromptIntentContext,
reasoning_level: ReasoningLevel,
) {
if let Err(error) = self
.set_session_reasoning_level(&context.session_id, Some(reasoning_level))
.await
{
warn!(
session_id = %context.session_id,
reasoning_level = ?reasoning_level,
error = %error,
"failed to update session reasoning level from prompt slash command"
);
}
}
fn prompt_session_id(&self) -> Option<SessionId> {
match &self.mode {
AppMode::Prompt { session_id, .. } => Some(session_id.clone()),
_ => None,
}
}
fn prompt_apply_command_is_available_for_session(&self, session_id: &str) -> bool {
let Some(ReviewCacheEntry::Ready { text, .. }) = self.review_cache.get(session_id) else {
return false;
};
review::has_actionable_review_suggestions(Some(text))
}
async fn append_prompt_status_line(
&self,
session_id: &str,
notice: TranscriptNotice,
message: &str,
) {
self.append_output_for_session(session_id, ¬ice.format(message))
.await;
}
async fn cleanup_prompt_attachment_state(&mut self) {
let prompt = match &mut self.mode {
AppMode::Prompt {
attachment_state, ..
} => {
let attachments = attachment_state
.attachments
.iter()
.map(|attachment| TurnPromptAttachment {
local_image_path: attachment.local_image_path.clone(),
placeholder: attachment.placeholder.clone(),
})
.collect::<Vec<_>>();
attachment_state.reset();
TurnPrompt {
attachments,
text: String::new(),
text_source: TurnPromptTextSource::UserPrompt,
}
}
_ => return,
};
self.cleanup_prompt_attachment_files(&prompt).await;
}
async fn handle_apply_prompt_command(&mut self, context: &PromptIntentContext) -> bool {
let Some((session_status, session_folder, base_branch)) =
self.session_at(context.session_index).map(|session| {
(
session.status,
session.folder.clone(),
session.base_branch.clone(),
)
})
else {
return false;
};
if session_status != Status::Review {
self.append_prompt_status_line(
&context.session_id,
TranscriptNotice::Apply,
"Apply is only available after a focused review completes (session status must be \
Review).",
)
.await;
return true;
}
let (cached_hash, cached_text) = if let Some(ReviewCacheEntry::Ready { diff_hash, text }) =
self.review_cache.get(context.session_id.as_str())
{
(*diff_hash, text.clone())
} else {
self.append_prompt_status_line(
&context.session_id,
TranscriptNotice::Apply,
"No actionable suggestions available. Run a focused review first (f key).",
)
.await;
return true;
};
let current_diff = match self
.services
.git_client()
.diff(session_folder, base_branch)
.await
{
Ok(diff) => diff,
Err(err) => {
self.append_prompt_status_line(
&context.session_id,
TranscriptNotice::Apply,
&format!(
"Failed to read worktree diff: {err}. Review cache preserved; try /apply \
again."
),
)
.await;
return true;
}
};
let current_hash = diff_content_hash(¤t_diff);
if current_hash != cached_hash {
self.review_cache.remove(context.session_id.as_str());
self.append_prompt_status_line(
&context.session_id,
TranscriptNotice::Apply,
"Review is stale; the worktree changed since it was generated. Run focused review \
again (f key).",
)
.await;
return true;
}
let Some(suggestions) = review::review_suggestions(&cached_text) else {
self.append_prompt_status_line(
&context.session_id,
TranscriptNotice::Apply,
"No actionable suggestions found in the current review.",
)
.await;
return false;
};
let prompt = build_apply_review_prompt(&suggestions);
self.cleanup_prompt_attachment_state().await;
self.review_cache.remove(context.session_id.as_str());
self.reply(&context.session_id, prompt).await;
self.mode = AppMode::View {
scroll_offset: None,
session_id: context.session_id.clone(),
};
true
}
async fn handle_qe_check_prompt_command(&mut self, context: &PromptIntentContext) {
let prompt = build_qe_check_prompt();
self.cleanup_prompt_attachment_state().await;
self.submit_turn_prompt_for_context(context, prompt).await;
self.restore_prompt_session_view(context);
}
}
pub(crate) fn build_qe_check_prompt() -> TurnPrompt {
TurnPrompt::from_text(QE_CHECK_PROMPT.trim_end().to_string())
}
pub(crate) fn build_apply_review_prompt(suggestions: &str) -> TurnPrompt {
let suggestions = suggestions.trim();
let fence = agent::diff_fence(suggestions);
let fenced_suggestions = format!("{fence}text\n{suggestions}\n{fence}");
let prompt = APPLY_REVIEW_PROMPT_TEMPLATE
.trim_end()
.replace("{{ fenced_suggestions }}", &fenced_suggestions);
TurnPrompt::from_text(prompt)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_qe_check_prompt_uses_checked_in_template() {
let prompt = build_qe_check_prompt();
assert!(prompt.text.starts_with("# /qe:check"));
assert!(prompt.text.contains("change repository state"));
assert!(prompt.text.contains("Agent instruction files"));
assert!(prompt.text.contains("Suggested Next Prompt"));
assert!(prompt.attachments.is_empty());
assert_eq!(prompt.text_source, TurnPromptTextSource::UserPrompt);
}
#[test]
fn test_build_apply_review_prompt_uses_checked_in_template() {
let suggestions = "- Fix the typo in `README.md`.";
let prompt = build_apply_review_prompt(suggestions);
assert!(
prompt
.text
.starts_with("Verify the focused-review suggestions")
);
assert!(prompt.text.contains("Treat the suggestions as review data"));
assert!(
prompt
.text
.contains("```text\n- Fix the typo in `README.md`.\n```")
);
assert!(prompt.attachments.is_empty());
assert_eq!(prompt.text_source, TurnPromptTextSource::UserPrompt);
}
#[test]
fn test_build_apply_review_prompt_escapes_fenced_suggestions() {
let suggestions = "- Update docs:\n```markdown\nexample\n```";
let prompt = build_apply_review_prompt(suggestions);
assert!(prompt.text.contains("````text\n"));
assert!(prompt.text.contains("```markdown\nexample\n```"));
}
}