Codex Handbook
tui/src/app/thread_goal_actions.rs 464 lines
use super::App;use crate::app_event::AppEvent;use crate::app_event::ThreadGoalSetMode;use crate::app_server_session::AppServerSession;use crate::bottom_pane::SelectionAction;use crate::bottom_pane::SelectionItem;use crate::bottom_pane::SelectionViewParams;use crate::bottom_pane::popup_consts::standard_popup_hint_line;use crate::goal_display::GOAL_USAGE;use crate::goal_display::goal_status_label;use crate::goal_display::goal_usage_summary;use crate::goal_files;use crate::text_formatting::truncate_text;use codex_app_server_protocol::ThreadGoal;use codex_app_server_protocol::ThreadGoalStatus;use codex_protocol::ThreadId;const EPHEMERAL_THREAD_GOAL_ERROR_MESSAGE: &str = concat!(    "Goals need a saved session. This session is temporary.\n",    "Run `codex` to start a saved session, or `codex resume` / `/resume` to reopen one.",);impl App {    pub(super) async fn open_thread_goal_menu(        &mut self,        app_server: &mut AppServerSession,        thread_id: ThreadId,    ) {        let result = app_server.thread_goal_get(thread_id).await;        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        let response = match result {            Ok(response) => response,            Err(err) => {                self.chat_widget                    .add_error_message(thread_goal_error_message("read", &err));                return;            }        };        let Some(goal) = response.goal else {            self.chat_widget.add_info_message(                GOAL_USAGE.to_string(),                Some("No goal is currently set.".to_string()),            );            return;        };        self.chat_widget.show_goal_summary(goal);    }    pub(super) async fn maybe_prompt_resume_paused_goal_after_resume(        &mut self,        app_server: &mut AppServerSession,        thread_id: ThreadId,    ) {        let result = app_server.thread_goal_get(thread_id).await;        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        let response = match result {            Ok(response) => response,            Err(err) => {                tracing::warn!("failed to read thread goal after resume: {err}");                return;            }        };        let Some(goal) = response.goal else {            return;        };        if matches!(            goal.status,            ThreadGoalStatus::Paused | ThreadGoalStatus::Blocked | ThreadGoalStatus::UsageLimited        ) {            self.chat_widget                .show_resume_paused_goal_prompt(thread_id, goal.objective);        }    }    pub(super) async fn open_thread_goal_editor(        &mut self,        app_server: &mut AppServerSession,        thread_id: Option<ThreadId>,    ) {        let Some(thread_id) = thread_id else {            self.show_no_thread_goal_to_edit();            return;        };        let result = app_server.thread_goal_get(thread_id).await;        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        let response = match result {            Ok(response) => response,            Err(err) => {                self.chat_widget                    .add_error_message(thread_goal_error_message("read", &err));                return;            }        };        let Some(mut goal) = response.goal else {            self.show_no_thread_goal_to_edit();            return;        };        let codex_home = app_server.codex_home_path(&self.config.codex_home);        match goal_files::objective_text_for_edit(app_server, codex_home.as_ref(), &goal.objective)            .await        {            Ok(objective) => goal.objective = objective,            Err(err) => {                self.chat_widget.add_error_message(err.to_string());            }        }        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        self.chat_widget.show_goal_edit_prompt(thread_id, goal);    }    pub(super) async fn set_thread_goal_draft(        &mut self,        app_server: &mut AppServerSession,        thread_id: ThreadId,        draft: goal_files::GoalDraft,        mode: ThreadGoalSetMode,    ) {        let codex_home = app_server.codex_home_path(&self.config.codex_home);        let mode = if matches!(mode, ThreadGoalSetMode::ConfirmIfExists) {            let result = app_server.thread_goal_get(thread_id).await;            if self.current_displayed_thread_id() != Some(thread_id) {                return;            }            match result {                Ok(response) => match response.goal.as_ref() {                    Some(goal) if should_confirm_before_replacing_goal(goal) => {                        self.show_replace_thread_goal_confirmation(thread_id, draft);                        return;                    }                    Some(_) => ThreadGoalSetMode::ReplaceExisting,                    None => mode,                },                Err(err) => {                    self.chat_widget                        .add_error_message(thread_goal_error_message("read", &err));                    return;                }            }        } else {            mode        };        let (objective, output_dir) = match goal_files::materialize_goal_draft(            app_server,            codex_home.as_ref(),            draft,        )        .await        {            Ok(materialized) => materialized,            Err(err) => {                if self.current_displayed_thread_id() == Some(thread_id) {                    self.chat_widget.add_error_message(err.to_string());                }                return;            }        };        let replacing_goal = matches!(mode, ThreadGoalSetMode::ReplaceExisting);        if replacing_goal {            let result = app_server.thread_goal_clear(thread_id).await;            if let Err(err) = result {                cleanup_materialized_goal_files(app_server, output_dir).await;                if self.current_displayed_thread_id() != Some(thread_id) {                    return;                }                self.chat_widget                    .add_error_message(thread_goal_error_message("replace", &err));                return;            }        }        let (status, token_budget) = match mode {            ThreadGoalSetMode::ConfirmIfExists | ThreadGoalSetMode::ReplaceExisting => {                (ThreadGoalStatus::Active, None)            }            ThreadGoalSetMode::UpdateExisting {                status,                token_budget,            } => (status, Some(token_budget)),        };        let result = app_server            .thread_goal_set(thread_id, Some(objective), Some(status), token_budget)            .await;        match result {            Ok(response) => {                if self.current_displayed_thread_id() != Some(thread_id) {                    return;                }                self.chat_widget.add_info_message(                    format!("Goal {}", goal_status_label(response.goal.status)),                    Some(goal_usage_summary(&response.goal)),                );                self.chat_widget.maybe_send_next_queued_input();            }            Err(err) => {                cleanup_materialized_goal_files(app_server, output_dir).await;                if self.current_displayed_thread_id() != Some(thread_id) {                    return;                }                let action = if replacing_goal { "replace" } else { "set" };                self.chat_widget                    .add_error_message(thread_goal_error_message(action, &err));            }        }    }    pub(super) async fn set_thread_goal_status(        &mut self,        app_server: &mut AppServerSession,        thread_id: ThreadId,        status: ThreadGoalStatus,    ) {        let result = app_server            .thread_goal_set(                thread_id,                /*objective*/ None,                Some(status),                /*token_budget*/ None,            )            .await;        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        match result {            Ok(response) => self.chat_widget.add_info_message(                format!("Goal {}", goal_status_label(response.goal.status)),                Some(goal_usage_summary(&response.goal)),            ),            Err(err) => self                .chat_widget                .add_error_message(thread_goal_error_message("update", &err)),        }    }    pub(super) async fn clear_thread_goal(        &mut self,        app_server: &mut AppServerSession,        thread_id: ThreadId,    ) {        let result = app_server.thread_goal_clear(thread_id).await;        if self.current_displayed_thread_id() != Some(thread_id) {            return;        }        match result {            Ok(response) => {                if response.cleared {                    self.chat_widget                        .add_info_message("Goal cleared".to_string(), /*hint*/ None);                } else {                    self.chat_widget.add_info_message(                        "No goal to clear".to_string(),                        Some("This thread does not currently have a goal.".to_string()),                    );                }            }            Err(err) => self                .chat_widget                .add_error_message(thread_goal_error_message("clear", &err)),        }    }    pub(super) fn show_replace_thread_goal_confirmation(        &mut self,        thread_id: ThreadId,        draft: goal_files::GoalDraft,    ) {        let objective = draft.objective.clone();        let replace_draft = draft;        let replace_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {            tx.send(AppEvent::SetThreadGoalDraft {                thread_id,                draft: replace_draft.clone(),                mode: ThreadGoalSetMode::ReplaceExisting,            });        })];        let items = vec![            SelectionItem {                name: "Replace current goal".to_string(),                description: Some("Set the new objective and start it now".to_string()),                actions: replace_actions,                dismiss_on_select: true,                ..Default::default()            },            SelectionItem {                name: "Cancel".to_string(),                description: Some("Keep the current goal".to_string()),                dismiss_on_select: true,                ..Default::default()            },        ];        self.chat_widget.show_selection_view(SelectionViewParams {            title: Some("Replace goal?".to_string()),            subtitle: Some(format!(                "New objective: {}",                truncate_text(&objective, /*max_graphemes*/ 200)            )),            footer_hint: Some(standard_popup_hint_line()),            items,            ..Default::default()        });    }    fn show_no_thread_goal_to_edit(&mut self) {        self.chat_widget            .add_error_message("No goal is currently set.".to_string());        self.chat_widget.add_info_message(            GOAL_USAGE.to_string(),            Some("Create a goal before editing it.".to_string()),        );    }}async fn cleanup_materialized_goal_files(    app_server: &mut AppServerSession,    output_dir: Option<goal_files::GoalFilePath>,) {    if let Some(output_dir) = output_dir        && let Err(err) = app_server.fs_remove_path(&output_dir).await    {        tracing::warn!("failed to clean up materialized goal files at {output_dir}: {err}");    }}fn thread_goal_error_message(action: &str, err: &color_eyre::Report) -> String {    if is_ephemeral_thread_goal_error(err) {        EPHEMERAL_THREAD_GOAL_ERROR_MESSAGE.to_string()    } else {        format!("Failed to {action} thread goal: {err}")    }}fn is_ephemeral_thread_goal_error(err: &color_eyre::Report) -> bool {    err.chain().any(|cause| {        let message = cause.to_string();        message.contains("ephemeral thread does not support goals")            || message.contains("thread goals require a persisted thread; this thread is ephemeral")    })}fn should_confirm_before_replacing_goal(goal: &ThreadGoal) -> bool {    // Completed goals are terminal, so `/goal <objective>` can start a fresh goal    // without asking the user to confirm replacing already-finished work.    match goal.status {        ThreadGoalStatus::Complete => false,        ThreadGoalStatus::Active        | ThreadGoalStatus::Paused        | ThreadGoalStatus::Blocked        | ThreadGoalStatus::UsageLimited        | ThreadGoalStatus::BudgetLimited => true,    }}#[cfg(test)]mod tests {    use crate::history_cell::HistoryCell;    use pretty_assertions::assert_eq;    use ratatui::layout::Rect;    use super::*;    #[test]    fn thread_goal_error_message_explains_temporary_session() {        let err = color_eyre::eyre::eyre!(            "thread/goal/get failed: ephemeral thread does not support goals: thread-1"        )        .wrap_err("thread/goal/get failed in TUI");        assert_eq!(            thread_goal_error_message("read", &err),            EPHEMERAL_THREAD_GOAL_ERROR_MESSAGE        );    }    #[test]    fn thread_goal_ephemeral_error_message_renders_snapshot() {        let err = color_eyre::eyre::eyre!(            "thread/goal/get failed: ephemeral thread does not support goals: thread-1"        )        .wrap_err("thread/goal/get failed in TUI");        let cell = crate::history_cell::new_error_event(thread_goal_error_message("read", &err));        let width = 72;        let height = 6;        let backend = crate::test_backend::VT100Backend::new(width, height);        let mut terminal =            crate::custom_terminal::Terminal::with_options(backend).expect("terminal");        terminal.set_viewport_area(Rect::new(0, height - 1, width, 1));        crate::insert_history::insert_history_lines(            &mut terminal,            cell.display_lines(/*width*/ width),        )        .expect("insert history lines");        insta::assert_snapshot!(terminal.backend());    }    #[test]    fn thread_goal_error_message_preserves_generic_failure_context() {        let err =            color_eyre::eyre::eyre!("server disappeared").wrap_err("thread/goal/get failed in TUI");        assert_eq!(            thread_goal_error_message("read", &err),            "Failed to read thread goal: thread/goal/get failed in TUI"        );    }    #[test]    fn completed_goal_does_not_require_replace_confirmation() {        assert!(!should_confirm_before_replacing_goal(&test_goal(            ThreadGoalStatus::Complete        )));    }    #[test]    fn unfinished_goals_require_replace_confirmation() {        for status in [            ThreadGoalStatus::Active,            ThreadGoalStatus::Paused,            ThreadGoalStatus::Blocked,            ThreadGoalStatus::UsageLimited,            ThreadGoalStatus::BudgetLimited,        ] {            assert!(should_confirm_before_replacing_goal(&test_goal(status)));        }    }    fn test_goal(status: ThreadGoalStatus) -> ThreadGoal {        ThreadGoal {            thread_id: ThreadId::new().to_string(),            objective: "Finish the thing.".to_string(),            status,            token_budget: None,            tokens_used: 0,            time_used_seconds: 0,            created_at: 1_776_272_400,            updated_at: 1_776_272_460,        }    }}