mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
feat(tui/cloud): add /cloud command for cloud task management
This commit is contained in:
Generated
+1
@@ -1447,6 +1447,7 @@ dependencies = [
|
||||
"codex-ansi-escape",
|
||||
"codex-arg0",
|
||||
"codex-browser",
|
||||
"codex-cloud-tasks-client",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-file-search",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user