feat(tui/cloud): add /cloud command for cloud task management

This commit is contained in:
James Peter
2025-10-01 18:58:27 +10:00
parent 252f63b18c
commit 080563be2d
11 changed files with 1244 additions and 0 deletions
+1
View File
@@ -1447,6 +1447,7 @@ dependencies = [
"codex-ansi-escape",
"codex-arg0",
"codex-browser",
"codex-cloud-tasks-client",
"codex-common",
"codex-core",
"codex-file-search",
+1
View File
@@ -43,6 +43,7 @@ codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
codex-protocol = { path = "../protocol" }
codex-version = { path = "../codex-version" }
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock"] }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
diffy = "0.4.2"
+140
View File
@@ -1,6 +1,7 @@
use crate::app_event::{AppEvent, TerminalRunController, TerminalRunEvent};
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::{ChatWidget, GhostState};
use crate::cloud_tasks_service;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
@@ -21,6 +22,8 @@ use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_core::ConversationManager;
use codex_login::{AuthManager, AuthMode, ServerOptions};
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::CloudTaskError;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -1694,6 +1697,11 @@ impl App<'_> {
}
}
}
SlashCommand::Cloud => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_command(command_args);
}
}
SlashCommand::Branch => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_branch_command(command_args);
@@ -2083,6 +2091,138 @@ impl App<'_> {
widget.show_review_custom_prompt();
}
}
AppEvent::FetchCloudTasks { environment } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_tasks_loading();
}
let tx = self.app_event_tx.clone();
let env_clone = environment.clone();
tokio::spawn(async move {
match cloud_tasks_service::fetch_tasks(environment).await {
Ok(tasks) => tx.send(AppEvent::PresentCloudTasks {
environment: env_clone,
tasks,
}),
Err(err) => tx.send(AppEvent::CloudTasksError {
message: err.to_string(),
}),
}
});
}
AppEvent::PresentCloudTasks { environment, tasks } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_cloud_tasks(environment, tasks);
}
}
AppEvent::CloudTasksError { message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_tasks_error(message);
}
}
AppEvent::FetchCloudEnvironments => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_environment_loading();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
match cloud_tasks_service::fetch_environments().await {
Ok(envs) => tx.send(AppEvent::PresentCloudEnvironments { environments: envs }),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::PresentCloudEnvironments { environments } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_cloud_environment_picker(environments);
}
}
AppEvent::SetCloudEnvironment { environment } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_cloud_environment(environment);
}
}
AppEvent::ShowCloudTaskActions { task_id } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_actions(task_id);
}
}
AppEvent::FetchCloudTaskDiff { task_id } => {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
match cloud_tasks_service::fetch_task_diff(task.clone()).await {
Ok(Some(diff)) => {
tx.send(AppEvent::DiffResult(diff));
}
Ok(None) => tx.send(AppEvent::CloudTasksError {
message: format!("Task {} has no diff available", task.0),
}),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::FetchCloudTaskMessages { task_id } => {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
match cloud_tasks_service::fetch_task_messages(task).await {
Ok(messages) if !messages.is_empty() => {
let joined = messages.join("\n\n");
tx.send(AppEvent::InsertBackgroundEvent {
message: format!("Cloud task output for {task_id}:\n{joined}"),
placement: crate::app_event::BackgroundPlacement::Tail,
});
}
Ok(_) => tx.send(AppEvent::CloudTasksError {
message: format!("Task {task_id} has no assistant messages"),
}),
Err(err) => tx.send(AppEvent::CloudTasksError { message: err.to_string() }),
}
});
}
AppEvent::ApplyCloudTask { task_id, preflight } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_apply_status(&task_id, preflight);
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let task = TaskId(task_id.clone());
let result = cloud_tasks_service::apply_task(task, preflight).await;
tx.send(AppEvent::CloudTaskApplyFinished {
task_id,
outcome: result.map_err(|err| CloudTaskError::Msg(err.to_string())),
preflight,
});
});
}
AppEvent::CloudTaskApplyFinished { task_id, outcome, preflight } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_task_apply_finished(task_id, outcome, preflight);
}
}
AppEvent::OpenCloudTaskCreate => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_create_prompt();
}
}
AppEvent::SubmitCloudTaskCreate { env_id, prompt, best_of_n } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_cloud_task_create_progress();
}
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = cloud_tasks_service::create_task(env_id.clone(), prompt.clone(), best_of_n).await;
tx.send(AppEvent::CloudTaskCreated {
env_id,
result: result.map_err(|err| CloudTaskError::Msg(err.to_string())),
});
});
}
AppEvent::CloudTaskCreated { env_id, result } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_cloud_task_created(env_id.clone(), result);
}
}
AppEvent::StartReviewCommitPicker => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_commit_loading();
+38
View File
@@ -14,6 +14,7 @@ use crate::streaming::StreamKind;
use std::time::Duration;
use codex_git_tooling::{GhostCommit, GitToolingError};
use codex_cloud_tasks_client::{ApplyOutcome, CloudTaskError, CreatedTask, TaskSummary};
use crate::app::ChatWidgetArgs;
use crate::bottom_pane::chrome_selection_view::ChromeLaunchOption;
@@ -22,6 +23,7 @@ use codex_protocol::models::ResponseItem;
use std::fmt;
use std::path::PathBuf;
use std::sync::mpsc::Sender as StdSender;
use crate::cloud_tasks_service::CloudEnvironment;
/// Wrapper to allow including non-Debug types in Debug enums without leaking internals.
pub(crate) struct Redacted<T>(pub T);
@@ -243,6 +245,42 @@ pub(crate) enum AppEvent {
/// Show the multi-line prompt input to collect custom review instructions.
OpenReviewCustomPrompt,
/// Cloud tasks: fetch the latest list based on the active environment filter.
FetchCloudTasks { environment: Option<String> },
/// Cloud tasks: response containing the refreshed task list.
PresentCloudTasks { environment: Option<String>, tasks: Vec<TaskSummary> },
/// Cloud tasks: generic error surfaced to the UI.
CloudTasksError { message: String },
/// Cloud tasks: fetch available environments to filter against.
FetchCloudEnvironments,
/// Cloud tasks: populated environment list ready for selection.
PresentCloudEnvironments { environments: Vec<CloudEnvironment> },
/// Cloud tasks: update the active environment filter (None = all environments).
SetCloudEnvironment { environment: Option<CloudEnvironment> },
/// Cloud tasks: show actions for a specific task.
ShowCloudTaskActions { task_id: String },
/// Cloud tasks: load diff for a task (current attempt).
FetchCloudTaskDiff { task_id: String },
/// Cloud tasks: load assistant messages for a task (current attempt).
FetchCloudTaskMessages { task_id: String },
/// Cloud tasks: run apply or preflight on a task.
ApplyCloudTask { task_id: String, preflight: bool },
/// Cloud tasks: apply/preflight finished.
CloudTaskApplyFinished {
task_id: String,
outcome: Result<ApplyOutcome, CloudTaskError>,
preflight: bool,
},
/// Cloud tasks: open the create-task prompt.
OpenCloudTaskCreate,
/// Cloud tasks: submit a new task creation request.
SubmitCloudTaskCreate { env_id: String, prompt: String, best_of_n: usize },
/// Cloud tasks: new task creation result.
CloudTaskCreated {
env_id: String,
result: Result<CreatedTask, CloudTaskError>,
},
/// Update the theme (with history event)
UpdateTheme(ThemeName),
/// Add or update a subagent command in memory (UI already persisted to config.toml)
@@ -0,0 +1,265 @@
use chrono::DateTime;
use chrono::Utc;
use codex_cloud_tasks_client::TaskStatus;
use codex_cloud_tasks_client::TaskSummary;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::{render_rows, GenericDisplayRow};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
const MAX_VISIBLE_ROWS: usize = 8;
pub(crate) struct CloudTasksView {
tasks: Vec<TaskSummary>,
rows: Vec<GenericDisplayRow>,
state: ScrollState,
env_label: String,
env_filter: Option<String>,
footer_hint: String,
app_event_tx: AppEventSender,
complete: bool,
}
impl CloudTasksView {
pub(crate) fn new(
tasks: Vec<TaskSummary>,
env_label: Option<String>,
env_filter: Option<String>,
app_event_tx: AppEventSender,
) -> Self {
let mut state = ScrollState::new();
if !tasks.is_empty() {
state.selected_idx = Some(0);
}
let rows = build_rows(&tasks);
let mut view = Self {
tasks,
rows,
state,
env_label: env_label.unwrap_or_else(|| "All environments".to_string()),
env_filter,
footer_hint: "↑↓ select · Enter actions · r refresh · n new · e environments · Esc close".to_string(),
app_event_tx,
complete: false,
};
view.state.clamp_selection(view.tasks.len());
view
}
fn selected_task_id(&self) -> Option<&str> {
self.state
.selected_idx
.and_then(|idx| self.tasks.get(idx))
.map(|task| task.id.0.as_str())
}
fn max_rows(&self) -> usize {
MAX_VISIBLE_ROWS.min(self.rows.len().max(1))
}
}
impl BottomPaneView<'_> for CloudTasksView {
fn handle_key_event(&mut self, _pane: &mut super::BottomPane<'_>, key: crossterm::event::KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Up => {
self.state.move_up_wrap(self.tasks.len());
self.state.ensure_visible(self.tasks.len(), self.max_rows());
}
KeyCode::Down => {
self.state.move_down_wrap(self.tasks.len());
self.state.ensure_visible(self.tasks.len(), self.max_rows());
}
KeyCode::Enter => {
if let Some(task_id) = self.selected_task_id() {
self.app_event_tx
.send(AppEvent::ShowCloudTaskActions { task_id: task_id.to_string() });
}
}
KeyCode::Esc => {
self.complete = true;
}
KeyCode::Char('r') | KeyCode::Char('R') => {
self.app_event_tx.send(AppEvent::FetchCloudTasks {
environment: self.env_filter.clone(),
});
}
KeyCode::Char('n') | KeyCode::Char('N') => {
self.app_event_tx.send(AppEvent::OpenCloudTaskCreate);
}
KeyCode::Char('e') | KeyCode::Char('E') => {
self.app_event_tx.send(AppEvent::FetchCloudEnvironments);
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane<'_>) -> super::CancellationEvent {
self.complete = true;
super::CancellationEvent::Handled
}
fn desired_height(&self, _width: u16) -> u16 {
let rows = self.max_rows() as u16;
// borders + env header + spacer + rows + footer
rows.saturating_add(5)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let title = format!(" Cloud tasks — {} ", self.env_label);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.title(title);
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 { return; }
// Header line (environment info)
let header_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let header_spans = vec![
Span::styled("▌ ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
format!("Environment: {}", self.env_label),
Style::default().fg(crate::colors::text()),
),
];
Paragraph::new(Line::from(header_spans)).render(header_area, buf);
// Footer hint
let footer_area = Rect {
x: inner.x,
y: inner.y.saturating_add(inner.height.saturating_sub(1)),
width: inner.width,
height: 1,
};
Paragraph::new(Line::from(vec![Span::styled(
self.footer_hint.clone(),
Style::default().fg(crate::colors::text_dim()),
)]))
.render(footer_area, buf);
if inner.height <= 2 {
return;
}
let list_area = Rect {
x: inner.x,
y: inner.y.saturating_add(1),
width: inner.width,
height: inner.height.saturating_sub(2),
};
render_rows(
list_area,
buf,
&self.rows,
&self.state,
self.max_rows(),
false,
);
}
}
fn build_rows(tasks: &[TaskSummary]) -> Vec<GenericDisplayRow> {
if tasks.is_empty() {
return vec![GenericDisplayRow {
name: "No cloud tasks available".to_string(),
match_indices: None,
is_current: false,
description: Some("Use `n` to create a new task or refresh with `r`.".to_string()),
name_color: None,
}];
}
tasks
.iter()
.map(|task| {
let (status_label, status_color) = format_status(&task.status);
let attempts = task.attempt_total.unwrap_or(1);
let attempt_label = if attempts > 1 {
format!(" · best-of {attempts}")
} else {
String::new()
};
let mut name = format!("{status_label} · {}", task.title);
name.push_str(&attempt_label);
let env_display = task
.environment_label
.as_ref()
.or(task.environment_id.as_ref())
.map(|env| format!("env: {env}"));
let diff_summary = format_diff_summary(&task.summary);
let updated = format_updated(task.updated_at);
let mut description_parts = vec![diff_summary, updated];
if let Some(env) = env_display {
description_parts.push(env);
}
let description = description_parts.join(" · ");
GenericDisplayRow {
name,
match_indices: None,
is_current: matches!(task.status, TaskStatus::Applied),
description: Some(description),
name_color: status_color,
}
})
.collect()
}
fn format_status(status: &TaskStatus) -> (&'static str, Option<ratatui::style::Color>) {
match status {
TaskStatus::Ready => ("READY", Some(crate::colors::success_green())),
TaskStatus::Pending => ("PENDING", Some(crate::colors::text_mid())),
TaskStatus::Applied => ("APPLIED", Some(crate::colors::primary())),
TaskStatus::Error => ("ERROR", Some(crate::colors::error())),
}
}
fn format_diff_summary(summary: &codex_cloud_tasks_client::DiffSummary) -> String {
format!(
"Δ {} files · +{} / -{}",
summary.files_changed,
summary.lines_added,
summary.lines_removed
)
}
fn format_updated(updated_at: DateTime<Utc>) -> String {
let now = Utc::now();
let delta = now.signed_duration_since(updated_at);
if delta.num_minutes() < 1 {
"updated just now".to_string()
} else if delta.num_minutes() < 60 {
format!("updated {}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("updated {}h ago", delta.num_hours())
} else {
format!("updated {}d ago", delta.num_days())
}
}
+9
View File
@@ -34,6 +34,8 @@ mod selection_popup_common;
pub mod list_selection_view;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use custom_prompt_view::CustomPromptView;
mod cloud_tasks_view;
pub(crate) use cloud_tasks_view::CloudTasksView;
pub mod resume_selection_view;
pub mod agents_settings_view;
mod github_settings_view;
@@ -633,6 +635,13 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub fn show_cloud_tasks(&mut self, view: CloudTasksView) {
self.active_view = Some(Box::new(view));
self.active_view_kind = ActiveViewKind::Other;
self.status_view_active = false;
self.request_redraw();
}
/// Show the resume selection UI with structured rows
pub fn show_resume_selection(
&mut self,
+505
View File
@@ -195,6 +195,13 @@ fn status_tokens_prefix() -> &'static str {
STATUS_TOKENS_PREFIX
}
fn describe_cloud_error(err: &CloudTaskError) -> String {
match err {
CloudTaskError::Msg(message) => message.clone(),
other => other.to_string(),
}
}
use crate::account_label::{account_display_label, account_mode_priority};
use crate::app_event::{
AppEvent,
@@ -210,6 +217,7 @@ use crate::app_event::{
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CustomPromptView;
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionAction, SelectionItem};
use crate::bottom_pane::CloudTasksView;
use crate::bottom_pane::validation_settings_view;
use crate::bottom_pane::validation_settings_view::{GroupStatus, ToolRow};
use crate::bottom_pane::BottomPane;
@@ -255,6 +263,7 @@ use crate::history::state::{
RateLimitsRecord,
TextTone,
};
use crate::cloud_tasks_service::CloudEnvironment;
use crate::slash_command::{ProcessedCommand, SlashCommand};
use crate::live_wrap::RowBuilder;
use crate::streaming::StreamKind;
@@ -276,6 +285,7 @@ use codex_core::config::set_github_check_on_push;
use codex_core::config::set_validation_group_enabled;
use codex_core::config::set_validation_tool_enabled;
use codex_file_search::FileMatch;
use codex_cloud_tasks_client::{ApplyOutcome, CloudTaskError, CreatedTask, TaskSummary};
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_core::config_types::{validation_tool_category, ValidationCategory};
@@ -659,6 +669,11 @@ pub(crate) struct ChatWidget<'a> {
auto_state: AutoCoordinatorUiState,
auto_handle: Option<AutoCoordinatorHandle>,
auto_history: AutoDriveHistory,
cloud_tasks_selected_env: Option<CloudEnvironment>,
cloud_tasks_environments: Vec<CloudEnvironment>,
cloud_tasks_last_tasks: Vec<TaskSummary>,
cloud_tasks_best_of_n: usize,
cloud_tasks_creation_inflight: bool,
// Event sequencing to preserve original order across streaming/tool events
// and stream-related flags moved into stream_state
@@ -2894,6 +2909,11 @@ impl ChatWidget<'_> {
auto_state: AutoCoordinatorUiState::default(),
auto_handle: None,
auto_history: AutoDriveHistory::new(),
cloud_tasks_selected_env: None,
cloud_tasks_environments: Vec::new(),
cloud_tasks_last_tasks: Vec::new(),
cloud_tasks_best_of_n: 1,
cloud_tasks_creation_inflight: false,
browser_is_external: false,
// Stable ordering & routing init
cell_order_seq: vec![OrderKey {
@@ -3149,6 +3169,11 @@ impl ChatWidget<'_> {
auto_state: AutoCoordinatorUiState::default(),
auto_handle: None,
auto_history: AutoDriveHistory::new(),
cloud_tasks_selected_env: None,
cloud_tasks_environments: Vec::new(),
cloud_tasks_last_tasks: Vec::new(),
cloud_tasks_best_of_n: 1,
cloud_tasks_creation_inflight: false,
browser_is_external: false,
// Strict ordering init for forked widget
cell_order_seq: vec![OrderKey {
@@ -17356,6 +17381,486 @@ impl ChatWidget<'_> {
/// Handle `/branch [task]` command. Creates a worktree under `.code/branches`,
/// optionally copies current uncommitted changes, then switches the session cwd
/// into the worktree. If `task` is non-empty, submits it immediately.
pub(crate) fn handle_cloud_command(&mut self, args: String) {
let trimmed = args.trim();
if trimmed.is_empty() {
self.open_cloud_menu();
return;
}
let mut parts = trimmed.splitn(2, ' ');
let head = parts.next().unwrap_or("").to_ascii_lowercase();
let rest = parts.next().map(str::trim).unwrap_or("");
match head.as_str() {
"list" => {
if rest.is_empty() {
self.request_cloud_task_refresh(None);
} else {
self.request_cloud_task_refresh(Some(rest.to_string()));
}
}
"env" => {
self.app_event_tx.send(AppEvent::FetchCloudEnvironments);
}
"new" => {
if rest.is_empty() {
self.show_cloud_task_create_prompt();
} else if let Some(env) = self.cloud_tasks_selected_env.clone() {
if self.cloud_tasks_creation_inflight {
self.bottom_pane.flash_footer_notice(
"Cloud task creation already in progress".to_string(),
);
} else {
self.cloud_tasks_creation_inflight = true;
self.app_event_tx.send(AppEvent::SubmitCloudTaskCreate {
env_id: env.id.clone(),
prompt: rest.to_string(),
best_of_n: self.cloud_tasks_best_of_n,
});
self.show_cloud_task_create_progress();
}
} else {
self.show_cloud_tasks_error(
"Select an environment before creating a cloud task".to_string(),
);
self.app_event_tx.send(AppEvent::FetchCloudEnvironments);
}
}
_ => {
self.history_push(history_cell::new_error_event(format!(
"`/cloud` — unknown option '{head}'. Try `/cloud`, `/cloud list`, `/cloud new`, or `/cloud env`."
)));
self.request_redraw();
}
}
}
fn open_cloud_menu(&mut self) {
let current = self.cloud_env_label();
let env_id = self
.cloud_tasks_selected_env
.as_ref()
.map(|env| env.id.clone());
let fetch_action_env = env_id.clone();
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Browse cloud tasks".to_string(),
description: Some(format!("Current filter: {current}")),
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::FetchCloudTasks {
environment: fetch_action_env.clone(),
});
})],
});
items.push(SelectionItem {
name: "Select environment".to_string(),
description: Some("Choose which environment to browse".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &AppEventSender| {
tx.send(AppEvent::FetchCloudEnvironments);
})],
});
items.push(SelectionItem {
name: "Create new task".to_string(),
description: Some("Open the composer to submit a new cloud task".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &AppEventSender| {
tx.send(AppEvent::OpenCloudTaskCreate);
})],
});
let view = ListSelectionView::new(
" Cloud tasks ".to_string(),
Some("Choose an action".to_string()),
Some("Enter select · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
6,
);
self.bottom_pane.show_list_selection(
"Cloud tasks".to_string(),
None,
None,
view,
);
}
pub(crate) fn show_cloud_tasks_loading(&mut self) {
let loading_item = SelectionItem {
name: "Loading cloud tasks…".to_string(),
description: Some("Fetching latest tasks from Codex Cloud".to_string()),
is_current: true,
actions: Vec::new(),
};
let view = ListSelectionView::new(
" Cloud tasks ".to_string(),
Some(self.cloud_env_label()),
Some("Esc cancel".to_string()),
vec![loading_item],
self.app_event_tx.clone(),
6,
);
self.bottom_pane.show_list_selection(
"Loading cloud tasks".to_string(),
None,
None,
view,
);
}
pub(crate) fn present_cloud_tasks(
&mut self,
environment: Option<String>,
tasks: Vec<TaskSummary>,
) {
self.cloud_tasks_last_tasks = tasks.clone();
let env_label = match environment {
Some(ref id) => self
.cloud_tasks_selected_env
.as_ref()
.filter(|env| env.id == *id)
.map(|env| self.display_name_for_env(env))
.unwrap_or_else(|| format!("Environment {id}")),
None => "All environments".to_string(),
};
let view = CloudTasksView::new(
tasks,
Some(env_label.clone()),
environment,
self.app_event_tx.clone(),
);
self.bottom_pane.show_cloud_tasks(view);
self.request_redraw();
}
pub(crate) fn show_cloud_tasks_error(&mut self, message: String) {
self.bottom_pane.flash_footer_notice(message.clone());
self.history_push(history_cell::new_error_event(format!(
"`/cloud` — {message}"
)));
self.request_redraw();
}
pub(crate) fn show_cloud_environment_loading(&mut self) {
let loading_item = SelectionItem {
name: "Loading environments…".to_string(),
description: Some("Fetching available Codex Cloud environments".to_string()),
is_current: true,
actions: Vec::new(),
};
let view = ListSelectionView::new(
" Select environment ".to_string(),
Some("Choose which environment to browse".to_string()),
Some("Esc cancel".to_string()),
vec![loading_item],
self.app_event_tx.clone(),
8,
);
self.bottom_pane.show_list_selection(
"Select environment".to_string(),
None,
None,
view,
);
}
pub(crate) fn present_cloud_environment_picker(
&mut self,
environments: Vec<CloudEnvironment>,
) {
if environments.is_empty() {
self.show_cloud_tasks_error("No environments available".to_string());
return;
}
self.cloud_tasks_environments = environments.clone();
let mut items: Vec<SelectionItem> = Vec::with_capacity(environments.len() + 1);
items.push(SelectionItem {
name: "All environments".to_string(),
description: Some("Show tasks across every environment".to_string()),
is_current: self.cloud_tasks_selected_env.is_none(),
actions: vec![Box::new(|tx: &AppEventSender| {
tx.send(AppEvent::SetCloudEnvironment { environment: None });
})],
});
for env in environments {
let env_clone = env.clone();
let display = self.display_name_for_env(&env_clone);
let repo_hint = env_clone
.repo_hints
.clone()
.map(|hint| format!("Repo: {hint}"));
items.push(SelectionItem {
name: display,
description: repo_hint,
is_current: self
.cloud_tasks_selected_env
.as_ref()
.map(|selected| selected.id == env_clone.id)
.unwrap_or(false),
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::SetCloudEnvironment {
environment: Some(env_clone.clone()),
});
})],
});
}
let view = ListSelectionView::new(
" Select environment ".to_string(),
Some("Pick the environment to browse".to_string()),
Some("Enter select · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
10,
);
self.bottom_pane.show_list_selection(
"Select environment".to_string(),
None,
None,
view,
);
}
pub(crate) fn set_cloud_environment(&mut self, environment: Option<CloudEnvironment>) {
self.cloud_tasks_selected_env = environment.clone();
let label = environment
.as_ref()
.map(|env| self.display_name_for_env(env))
.unwrap_or_else(|| "All environments".to_string());
self.bottom_pane
.flash_footer_notice(format!("Cloud tasks filter set to {label}"));
self.request_cloud_task_refresh(None);
}
fn display_name_for_env(&self, env: &CloudEnvironment) -> String {
match env.label.as_ref() {
Some(label) if !label.is_empty() => format!("{label} ({})", env.id),
_ => env.id.clone(),
}
}
fn request_cloud_task_refresh(&mut self, env_override: Option<String>) {
let selected = env_override.or_else(|| self.current_cloud_env_id());
self.app_event_tx
.send(AppEvent::FetchCloudTasks { environment: selected });
}
fn cloud_env_label(&self) -> String {
self.cloud_tasks_selected_env
.as_ref()
.map(|env| self.display_name_for_env(env))
.unwrap_or_else(|| "All environments".to_string())
}
fn current_cloud_env_id(&self) -> Option<String> {
self.cloud_tasks_selected_env
.as_ref()
.map(|env| env.id.clone())
}
pub(crate) fn show_cloud_task_actions(&mut self, task_id: String) {
let Some(task) = self.find_cloud_task(&task_id) else {
self.show_cloud_tasks_error(format!("Task {task_id} no longer available"));
return;
};
let status_label = format!("Status: {:?}", task.status);
let env_display = task
.environment_label
.as_ref()
.or(task.environment_id.as_ref())
.cloned()
.unwrap_or_else(|| "Unknown environment".to_string());
let mut items: Vec<SelectionItem> = Vec::new();
let diff_id = task.id.0.clone();
items.push(SelectionItem {
name: "View diff".to_string(),
description: Some("Open the unified diff in history".to_string()),
is_current: true,
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::FetchCloudTaskDiff {
task_id: diff_id.clone(),
});
})],
});
let msg_id = task.id.0.clone();
items.push(SelectionItem {
name: "View assistant output".to_string(),
description: Some("Show assistant messages associated with this task".to_string()),
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::FetchCloudTaskMessages {
task_id: msg_id.clone(),
});
})],
});
let preflight_id = task.id.0.clone();
items.push(SelectionItem {
name: "Preflight apply".to_string(),
description: Some("Check whether the patch applies cleanly".to_string()),
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::ApplyCloudTask {
task_id: preflight_id.clone(),
preflight: true,
});
})],
});
let apply_id = task.id.0.clone();
items.push(SelectionItem {
name: "Apply task".to_string(),
description: Some("Apply the diff to the working tree".to_string()),
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::ApplyCloudTask {
task_id: apply_id.clone(),
preflight: false,
});
})],
});
let subtitle = format!("{} • Env: {}", status_label, env_display);
let view = ListSelectionView::new(
format!(" Task {} ", task.title),
Some(subtitle),
Some("Enter choose · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
8,
);
self.bottom_pane.show_list_selection(
format!("Cloud task: {}", task.title),
None,
None,
view,
);
}
pub(crate) fn show_cloud_task_create_prompt(&mut self) {
let Some(env) = self.cloud_tasks_selected_env.clone() else {
self.show_cloud_tasks_error(
"Select an environment before creating a cloud task".to_string(),
);
return;
};
let env_display = self.display_name_for_env(&env);
let submit_tx = self.app_event_tx.clone();
let env_id = env.id.clone();
let best_of = self.cloud_tasks_best_of_n;
let on_submit: Box<dyn Fn(String) + Send + Sync> = Box::new(move |text: String| {
if text.trim().is_empty() {
return;
}
submit_tx.send(AppEvent::SubmitCloudTaskCreate {
env_id: env_id.clone(),
prompt: text,
best_of_n: best_of,
});
});
let view = CustomPromptView::new(
format!("Create cloud task ({env_display})"),
"Describe the change you want Codex to implement".to_string(),
Some("Press Enter to submit · Esc cancel".to_string()),
self.app_event_tx.clone(),
None,
on_submit,
);
self.bottom_pane.show_custom_prompt(view);
}
pub(crate) fn show_cloud_task_create_progress(&mut self) {
self.cloud_tasks_creation_inflight = true;
self.bottom_pane
.flash_footer_notice("Submitting cloud task…".to_string());
self.request_redraw();
}
pub(crate) fn handle_cloud_task_created(
&mut self,
env_id: String,
result: Result<CreatedTask, CloudTaskError>,
) {
self.cloud_tasks_creation_inflight = false;
match result {
Ok(created) => {
self.app_event_tx.send_background_event(format!(
"✅ Created cloud task {} in {env_id}",
created.id.0
));
self.request_cloud_task_refresh(None);
}
Err(err) => {
self.show_cloud_tasks_error(format!(
"Failed to create cloud task in {env_id}: {}",
describe_cloud_error(&err)
));
}
}
}
pub(crate) fn show_cloud_task_apply_status(&mut self, task_id: &str, preflight: bool) {
if preflight {
self.app_event_tx
.send_background_event(format!("Preflighting cloud task {task_id}…"));
} else {
self.app_event_tx
.send_background_event(format!("Applying cloud task {task_id}…"));
}
}
pub(crate) fn handle_cloud_task_apply_finished(
&mut self,
task_id: String,
outcome: Result<ApplyOutcome, CloudTaskError>,
preflight: bool,
) {
match outcome {
Ok(result) => {
let mut message = if preflight {
format!("Preflight result for {task_id}: {}", result.message)
} else {
format!("Apply result for {task_id}: {}", result.message)
};
if !result.skipped_paths.is_empty() {
message.push_str("\nSkipped: ");
message.push_str(&result.skipped_paths.join(", "));
}
if !result.conflict_paths.is_empty() {
message.push_str("\nConflicts: ");
message.push_str(&result.conflict_paths.join(", "));
}
self.app_event_tx.send_background_event(message);
if !preflight {
self.request_cloud_task_refresh(None);
}
}
Err(err) => {
self.show_cloud_tasks_error(format!(
"Cloud task {task_id} failed: {}",
describe_cloud_error(&err)
));
}
}
}
fn find_cloud_task(&self, task_id: &str) -> Option<&TaskSummary> {
self.cloud_tasks_last_tasks
.iter()
.find(|task| task.id.0 == task_id)
}
pub(crate) fn handle_branch_command(&mut self, args: String) {
if Self::is_branch_worktree_path(&self.config.cwd) {
self.history_push(crate::history_cell::new_error_event(
+280
View File
@@ -0,0 +1,280 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use codex_cloud_tasks_client::ApplyOutcome;
use codex_cloud_tasks_client::CloudBackend;
use codex_cloud_tasks_client::CreatedTask;
use codex_cloud_tasks_client::HttpClient;
use codex_cloud_tasks_client::MockClient;
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::TaskSummary;
use codex_login::AuthManager;
use codex_login::AuthMode;
use reqwest::header::HeaderMap;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use reqwest::header::AUTHORIZATION;
use reqwest::header::USER_AGENT;
use serde::Deserialize;
#[derive(Clone, Debug)]
pub struct CloudEnvironment {
pub id: String,
pub label: Option<String>,
pub repo_hints: Option<String>,
pub is_pinned: bool,
}
struct CloudTasksConfig {
base_url: String,
token: Option<String>,
account_id: Option<String>,
use_mock: bool,
}
pub async fn fetch_tasks(environment: Option<String>) -> Result<Vec<TaskSummary>> {
let config = load_config().await?;
let backend = build_backend(&config)?;
backend
.list_tasks(environment.as_deref())
.await
.map_err(|err| anyhow!("list cloud tasks failed: {err}"))
}
pub async fn fetch_task_diff(task_id: TaskId) -> Result<Option<String>> {
let config = load_config().await?;
let backend = build_backend(&config)?;
backend
.get_task_diff(task_id.clone())
.await
.map_err(|err| anyhow!("fetch diff for {} failed: {err}", task_id.0))
}
pub async fn fetch_task_messages(task_id: TaskId) -> Result<Vec<String>> {
let config = load_config().await?;
let backend = build_backend(&config)?;
backend
.get_task_messages(task_id.clone())
.await
.map_err(|err| anyhow!("fetch messages for {} failed: {err}", task_id.0))
}
pub async fn apply_task(task_id: TaskId, preflight: bool) -> Result<ApplyOutcome> {
let config = load_config().await?;
let backend = build_backend(&config)?;
let fut = if preflight {
backend.apply_task_preflight(task_id.clone(), None)
} else {
backend.apply_task(task_id.clone(), None)
};
fut.await.map_err(|err| anyhow!("apply task {} failed: {err}", task_id.0))
}
pub async fn create_task(env_id: String, prompt: String, best_of_n: usize) -> Result<CreatedTask> {
let config = load_config().await?;
if config.use_mock {
let backend = build_backend(&config)?;
return backend
.create_task(&env_id, &prompt, "main", false, best_of_n)
.await
.map_err(|err| anyhow!("create mock task failed: {err}"));
}
let backend = build_backend(&config)?;
let git_ref = detect_git_ref().await.unwrap_or_else(|| "main".to_string());
backend
.create_task(&env_id, &prompt, &git_ref, false, best_of_n)
.await
.map_err(|err| anyhow!("create task failed: {err}"))
}
pub async fn fetch_environments() -> Result<Vec<CloudEnvironment>> {
let config = load_config().await?;
if config.use_mock {
return Ok(vec![CloudEnvironment {
id: "mock".to_string(),
label: Some("Mock environment".to_string()),
repo_hints: None,
is_pinned: true,
}]);
}
let client = reqwest::Client::builder()
.build()
.context("build reqwest client")?;
let mut headers = HeaderMap::new();
let ua = codex_core::default_client::get_codex_user_agent(None);
headers.insert(
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Some(token) = &config.token {
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}")) {
headers.insert(AUTHORIZATION, value);
}
}
if let Some(account) = &config.account_id {
if let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(value) = HeaderValue::from_str(account)
{
headers.insert(name, value);
}
}
let url = environments_url(&config.base_url);
let response = client
.get(url.clone())
.headers(headers)
.send()
.await
.map_err(|err| anyhow!("GET {url} failed: {err}"))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("GET {url} failed: {status}; body={body}"));
}
let entries: Vec<EnvironmentEntry> = response
.json()
.await
.map_err(|err| anyhow!("decode environments response failed: {err}"))?;
let mut map: HashMap<String, CloudEnvironment> = HashMap::new();
for entry in entries {
let e = map.entry(entry.id.clone()).or_insert_with(|| CloudEnvironment {
id: entry.id,
label: entry.label.clone(),
repo_hints: entry.repo_hints.clone(),
is_pinned: entry.is_pinned.unwrap_or(false),
});
if e.label.is_none() {
e.label = entry.label;
}
if e.repo_hints.is_none() {
e.repo_hints = entry.repo_hints;
}
e.is_pinned = e.is_pinned || entry.is_pinned.unwrap_or(false);
}
let mut environments: Vec<CloudEnvironment> = map.into_values().collect();
environments.sort_by(|a, b| {
b.is_pinned
.cmp(&a.is_pinned)
.then_with(|| a.label.as_deref().unwrap_or("").cmp(b.label.as_deref().unwrap_or("")))
.then_with(|| a.id.cmp(&b.id))
});
Ok(environments)
}
fn environments_url(base_url: &str) -> String {
if base_url.contains("/backend-api") {
format!("{base_url}/wham/environments")
} else {
format!("{base_url}/api/codex/environments")
}
}
async fn detect_git_ref() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
codex_core::git_info::current_branch_name(&cwd).await
}
fn build_backend(config: &CloudTasksConfig) -> Result<Arc<dyn CloudBackend>> {
if config.use_mock {
return Ok(Arc::new(MockClient));
}
let ua = codex_core::default_client::get_codex_user_agent(None);
let mut client = HttpClient::new(config.base_url.clone()).context("create cloud http client")?;
client = client.with_user_agent(ua);
if let Some(token) = &config.token {
client = client.with_bearer_token(token.clone());
}
if let Some(account) = &config.account_id {
client = client.with_chatgpt_account_id(account.clone());
}
Ok(Arc::new(client))
}
async fn load_config() -> Result<CloudTasksConfig> {
let base_url_env = std::env::var("CODEX_CLOUD_TASKS_BASE_URL")
.unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string());
let base_url = normalize_base_url(&base_url_env);
let use_mock = std::env::var("CODEX_CLOUD_TASKS_MODE")
.ok()
.map(|mode| mode.eq_ignore_ascii_case("mock"))
.unwrap_or(false);
if use_mock {
return Ok(CloudTasksConfig {
base_url,
token: None,
account_id: None,
use_mock,
});
}
let codex_home = codex_core::config::find_codex_home()
.context("determine codex home directory")?;
let auth_manager = AuthManager::new(
codex_home,
AuthMode::ChatGPT,
codex_core::default_client::DEFAULT_ORIGINATOR.to_string(),
);
let auth = auth_manager
.auth()
.ok_or_else(|| anyhow!("Not signed in. Run `codex login` to authenticate with ChatGPT."))?;
let token = auth
.get_token()
.await
.context("retrieve ChatGPT access token")?;
if token.is_empty() {
return Err(anyhow!("ChatGPT access token is empty"));
}
let account_id = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&token));
Ok(CloudTasksConfig {
base_url,
token: Some(token),
account_id,
use_mock: false,
})
}
fn normalize_base_url(input: &str) -> String {
let mut url = input.trim().to_string();
while url.ends_with('/') {
url.pop();
}
if (url.starts_with("https://chatgpt.com") || url.starts_with("https://chat.openai.com"))
&& !url.contains("/backend-api")
{
url.push_str("/backend-api");
}
url
}
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
let mut parts = token.split('.');
let (_header, payload, _sig) = (parts.next()?, parts.next()?, parts.next()?);
let data = URL_SAFE_NO_PAD.decode(payload).ok()?;
let json: serde_json::Value = serde_json::from_slice(&data).ok()?;
json.get("https://api.openai.com/auth")
.and_then(|auth| auth.get("chatgpt_account_id"))
.and_then(|id| id.as_str())
.map(|s| s.to_string())
}
#[derive(Debug, Deserialize)]
struct EnvironmentEntry {
id: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
repo_hints: Option<String>,
#[serde(default)]
is_pinned: Option<bool>,
}
+1
View File
@@ -36,6 +36,7 @@ mod account_label;
mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cloud_tasks_service;
mod cli;
mod common;
mod colors;
+2
View File
@@ -57,6 +57,7 @@ pub enum SlashCommand {
Compact,
Undo,
Review,
Cloud,
Diff,
Mention,
Cmd,
@@ -108,6 +109,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Undo => "restore the workspace to the last Code snapshot",
SlashCommand::Review => "review your changes for potential issues",
SlashCommand::Cloud => "browse, apply, and create cloud tasks",
SlashCommand::Quit => "exit Code",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
+2
View File
@@ -36,6 +36,8 @@ Notes
the workspace, a specific commit, compare against another branch, or enter
custom instructions. With a focus argument, skips the picker and uses your
text directly.
- `/cloud`: browse Codex Cloud tasks, view details, apply patches, and create
new tasks from the TUI.
- `/cmd <name>`: run a project command defined for the current workspace.
## UX & Display