use std::path::PathBuf;
use tracing::warn;
use crate::app::{App, ReviewCacheEntry, SessionStatsUsage, 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,
};
use crate::ui::text_util::format_token_count;
const QE_CHECK_PROMPT: &str = include_str!("qe_check_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;
}
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;
}
self.mode = AppMode::View {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: context.session_id.clone(),
};
}
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("/stats")) => {
self.reset_prompt_slash_input();
self.handle_stats_prompt_command(context).await;
}
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(),
"Paste Image Error",
&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 {
review_status_message: self.prompt_review_status_message(),
review_text: self.prompt_review_text(),
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()),
}
}
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_review_status_message(&self) -> Option<String> {
match &self.mode {
AppMode::Prompt {
review_status_message,
..
} => review_status_message.clone(),
_ => None,
}
}
fn prompt_review_text(&self) -> Option<String> {
match &self.mode {
AppMode::Prompt { review_text, .. } => review_text.clone(),
_ => None,
}
}
fn clear_prompt_review_state(&mut self) {
if let AppMode::Prompt {
review_status_message,
review_text,
..
} = &mut self.mode
{
*review_status_message = None;
*review_text = None;
}
}
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, label: &str, message: &str) {
self.append_output_for_session(session_id, &format!("\n[{label}] {message}\n"))
.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,
"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,
"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,
"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.clear_prompt_review_state();
self.append_prompt_status_line(
&context.session_id,
"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,
"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 {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: context.session_id.clone(),
};
true
}
async fn handle_stats_prompt_command(&self, context: &PromptIntentContext) {
let session_stats = self.stats_for_session(&context.session_id).await;
let session_time = session_stats
.session_duration_seconds
.map_or_else(|| "Unavailable".to_string(), format_duration);
let usage_rows_result = build_token_usage_rows(session_stats.usage_rows_result);
let stats_output =
build_stats_markdown(&context.session_id, &session_time, usage_rows_result);
self.append_output_for_session(&context.session_id, &stats_output)
.await;
}
async fn handle_qe_check_prompt_command(&mut self, context: &PromptIntentContext) {
let prompt = build_qe_check_prompt();
self.cleanup_prompt_attachment_state().await;
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 {
self.reply(&context.session_id, prompt).await;
}
self.mode = AppMode::View {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: context.session_id.clone(),
};
}
}
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 {
TurnPrompt::from_text(format!(
"Verify the following focused-review suggestions against the current code before changing \
anything. Apply only the suggestions that are still correct and relevant; explain any \
suggestions you leave unapplied.\n\n{suggestions}"
))
}
pub(crate) struct TokenUsageRow {
pub(crate) in_tokens: String,
pub(crate) model: String,
pub(crate) out_tokens: String,
}
pub(crate) fn build_token_usage_rows(
usage_rows_result: Result<Vec<SessionStatsUsage>, String>,
) -> Result<Vec<TokenUsageRow>, String> {
match usage_rows_result {
Ok(usage_rows) => {
let rows = usage_rows
.into_iter()
.map(|row| TokenUsageRow {
in_tokens: format_token_count(row.input_tokens),
model: row.model,
out_tokens: format_token_count(row.output_tokens),
})
.collect();
Ok(rows)
}
Err(error) => Err(error),
}
}
pub(crate) fn build_stats_markdown(
session_id: &str,
session_time: &str,
usage_rows_result: Result<Vec<TokenUsageRow>, String>,
) -> String {
let mut lines = vec![
format_stats_metric_line("Session ID", session_id),
format_stats_metric_line("Session Time", session_time),
String::new(),
"Tokens Usage".to_string(),
];
lines.extend(build_token_usage_lines(usage_rows_result));
format!(
"\n## Session Stats\n\n```stats\n{}\n```\n",
lines.join("\n")
)
}
pub(crate) fn format_stats_metric_line(metric: &str, value: &str) -> String {
format!("{metric}\t{value}")
}
pub(crate) fn build_token_usage_lines(
usage_rows_result: Result<Vec<TokenUsageRow>, String>,
) -> Vec<String> {
match usage_rows_result {
Ok(usage_rows) if usage_rows.is_empty() => vec!["No token usage recorded.".to_string()],
Ok(usage_rows) => render_token_usage_table_lines(&usage_rows),
Err(error) => vec![
"Usage unavailable.".to_string(),
format_stats_metric_line("Error", &error),
],
}
}
pub(crate) fn render_token_usage_table_lines(usage_rows: &[TokenUsageRow]) -> Vec<String> {
let model_width = usage_rows
.iter()
.map(|row| row.model.chars().count())
.max()
.unwrap_or_default()
.max("Model".chars().count());
let in_width = usage_rows
.iter()
.map(|row| row.in_tokens.chars().count())
.max()
.unwrap_or_default()
.max("In".chars().count());
let out_width = usage_rows
.iter()
.map(|row| row.out_tokens.chars().count())
.max()
.unwrap_or_default()
.max("Out".chars().count());
let mut lines = vec![format!(
"{:<model_width$} {:>in_width$} {:>out_width$}",
"Model", "In", "Out"
)];
lines.extend(usage_rows.iter().map(|row| {
format!(
"{:<model_width$} {:>in_width$} {:>out_width$}",
row.model, row.in_tokens, row.out_tokens
)
}));
lines
}
pub(crate) fn format_duration(total_seconds: i64) -> String {
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{hours:02}:{minutes:02}:{seconds:02}")
}
#[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);
}
}