This commit is contained in:
Eric Traut
2026-03-14 14:07:14 -06:00
parent be8c53a36d
commit fb3a9ec842
13 changed files with 753 additions and 160 deletions
+9
View File
@@ -576,6 +576,15 @@ impl InProcessAppServerClient {
})?
}
/// Reloads managed auth state from disk for the embedded runtime.
///
/// This keeps embedders off direct `AuthManager` access while still
/// allowing onboarding flows to observe auth changes performed outside the
/// app-server request path.
pub fn reload_auth_from_storage(&self) -> bool {
self.shared_core.auth_manager.reload()
}
/// Returns the next in-process event, or `None` when worker exits.
///
/// Callers are expected to drain this stream promptly. If they fall behind,
+101 -13
View File
@@ -1375,6 +1375,69 @@ impl App {
let thread_label = Some(self.thread_label(thread_id));
match update {
ThreadUpdate::CommandExecutionRequestApproval { params, .. } => {
let network_approval_context: Option<
codex_protocol::approvals::NetworkApprovalContext,
> = params.network_approval_context.clone().and_then(|context| {
serde_json::from_value(serde_json::to_value(context).ok()?).ok()
});
let additional_permissions: Option<codex_protocol::models::PermissionProfile> =
params
.additional_permissions
.clone()
.and_then(|permissions| {
serde_json::from_value(serde_json::to_value(permissions).ok()?).ok()
});
let proposed_execpolicy_amendment: Option<
codex_protocol::approvals::ExecPolicyAmendment,
> = params
.proposed_execpolicy_amendment
.clone()
.and_then(|amendment| {
serde_json::from_value(serde_json::to_value(amendment).ok()?).ok()
});
let proposed_network_policy_amendments: Option<
Vec<codex_protocol::approvals::NetworkPolicyAmendment>,
> = params
.proposed_network_policy_amendments
.clone()
.and_then(|amendments| {
serde_json::from_value(serde_json::to_value(amendments).ok()?).ok()
});
let available_decisions = match params.available_decisions.clone() {
Some(decisions) => decisions
.into_iter()
.map(|decision| match decision {
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept => {
codex_protocol::protocol::ReviewDecision::Approved
}
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => {
codex_protocol::protocol::ReviewDecision::ApprovedForSession
}
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment {
execpolicy_amendment,
} => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: execpolicy_amendment.into_core(),
},
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => codex_protocol::protocol::ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: network_policy_amendment.into_core(),
},
codex_app_server_protocol::CommandExecutionApprovalDecision::Decline => {
codex_protocol::protocol::ReviewDecision::Denied
}
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel => {
codex_protocol::protocol::ReviewDecision::Abort
}
})
.collect(),
None => codex_protocol::approvals::ExecApprovalRequestEvent::default_available_decisions(
network_approval_context.as_ref(),
proposed_execpolicy_amendment.as_ref(),
proposed_network_policy_amendments.as_deref(),
additional_permissions.as_ref(),
),
};
Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
thread_id,
thread_label,
@@ -1388,15 +1451,9 @@ impl App {
.map(|command| vec![command.clone()])
.unwrap_or_default(),
reason: params.reason.clone(),
available_decisions: Vec::new(),
network_approval_context: params.network_approval_context.clone().and_then(
|context| serde_json::from_value(serde_json::to_value(context).ok()?).ok(),
),
additional_permissions: params.additional_permissions.clone().and_then(
|permissions| {
serde_json::from_value(serde_json::to_value(permissions).ok()?).ok()
},
),
available_decisions,
network_approval_context,
additional_permissions,
}))
}
ThreadUpdate::FileChangeRequestApproval { params, .. } => Some(
@@ -3806,10 +3863,6 @@ impl App {
AppRunControl::Exit(ExitReason::UserRequested)
}
}
ExitMode::Immediate => {
self.pending_shutdown_exit_thread_id = None;
AppRunControl::Exit(ExitReason::UserRequested)
}
}
}
@@ -4536,6 +4589,41 @@ mod tests {
panic!("expected approval action to submit a thread-scoped op");
}
#[tokio::test]
async fn background_exec_approval_uses_default_available_decisions() -> Result<()> {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let update = test_exec_approval_update(thread_id, "/tmp/project", "call-1", "turn-1", None);
let request = app
.interactive_request_for_thread_update(thread_id, &update)
.await
.expect("approval should be created");
match request {
ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
available_decisions,
..
}) => {
assert_eq!(
available_decisions,
vec![
codex_protocol::protocol::ReviewDecision::Approved,
codex_protocol::protocol::ReviewDecision::Abort,
]
);
}
ThreadInteractiveRequest::Approval(_) => {
panic!("expected exec approval request")
}
ThreadInteractiveRequest::McpServerElicitation(_) => {
panic!("expected exec approval request")
}
}
Ok(())
}
#[tokio::test]
async fn routed_thread_event_does_not_recreate_channel_after_reset() -> Result<()> {
let mut app = make_test_app().await;
+86 -5
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use super::App;
@@ -85,6 +86,8 @@ use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_permissions::PermissionGrantScope;
use serde::de::DeserializeOwned;
use serde_json::Value;
use tokio::fs;
use tokio::io::AsyncReadExt;
use crate::thread_update::ThreadUpdate;
@@ -227,7 +230,12 @@ impl App {
"thread/start",
)
.await?;
session_configured_from_thread_start_response(&response)
let (history_log_id, history_entry_count) = history_metadata(config).await;
session_configured_from_thread_start_response(
&response,
history_log_id,
history_entry_count,
)
}
pub(super) async fn thread_resume_via_app_server(
@@ -245,7 +253,12 @@ impl App {
"thread/resume",
)
.await?;
session_configured_from_thread_resume_response(&response)
let (history_log_id, history_entry_count) = history_metadata(config).await;
session_configured_from_thread_resume_response(
&response,
history_log_id,
history_entry_count,
)
}
pub(super) async fn thread_fork_via_app_server(
@@ -263,7 +276,8 @@ impl App {
"thread/fork",
)
.await?;
session_configured_from_thread_fork_response(&response)
let (history_log_id, history_entry_count) = history_metadata(config).await;
session_configured_from_thread_fork_response(&response, history_log_id, history_entry_count)
}
pub(super) async fn unsubscribe_thread_via_app_server(
@@ -1240,6 +1254,8 @@ fn sandbox_mode_from_policy(sandbox_policy: &SandboxPolicy) -> Option<SandboxMod
fn session_configured_from_thread_start_response(
response: &ThreadStartResponse,
history_log_id: u64,
history_entry_count: usize,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
@@ -1253,11 +1269,15 @@ fn session_configured_from_thread_start_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
history_log_id,
history_entry_count,
)
}
fn session_configured_from_thread_resume_response(
response: &ThreadResumeResponse,
history_log_id: u64,
history_entry_count: usize,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
@@ -1271,11 +1291,15 @@ fn session_configured_from_thread_resume_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
history_log_id,
history_entry_count,
)
}
fn session_configured_from_thread_fork_response(
response: &ThreadForkResponse,
history_log_id: u64,
history_entry_count: usize,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
@@ -1289,6 +1313,8 @@ fn session_configured_from_thread_fork_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
history_log_id,
history_entry_count,
)
}
@@ -1308,6 +1334,8 @@ fn session_configured_from_thread_response(
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
history_log_id: u64,
history_entry_count: usize,
) -> Result<SessionConfiguredEvent, String> {
let session_id = ThreadId::from_string(thread_id)
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
@@ -1324,14 +1352,67 @@ fn session_configured_from_thread_response(
sandbox_policy,
cwd,
reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
history_log_id,
history_entry_count,
initial_messages: None,
network_proxy: None,
rollout_path,
})
}
async fn history_metadata(config: &Config) -> (u64, usize) {
let path = history_filepath(config);
history_metadata_for_file(&path).await
}
fn history_filepath(config: &Config) -> PathBuf {
config.codex_home.join("history.jsonl")
}
async fn history_metadata_for_file(path: &Path) -> (u64, usize) {
let log_id = match fs::metadata(path).await {
Ok(metadata) => history_log_id(&metadata).unwrap_or(0),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (0, 0),
Err(_) => return (0, 0),
};
let mut file = match fs::File::open(path).await {
Ok(file) => file,
Err(_) => return (log_id, 0),
};
let mut buf = [0u8; 8192];
let mut count = 0usize;
loop {
match file.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
count += buf[..n].iter().filter(|&&b| b == b'\n').count();
}
Err(_) => return (log_id, 0),
}
}
(log_id, count)
}
#[cfg(unix)]
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
Some(metadata.ino())
}
#[cfg(windows)]
fn history_log_id(metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::windows::fs::MetadataExt;
Some(metadata.creation_time())
}
#[cfg(not(any(unix, windows)))]
fn history_log_id(_metadata: &std::fs::Metadata) -> Option<u64> {
None
}
fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget {
match target {
ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges,
+2 -10
View File
@@ -95,9 +95,7 @@ pub(crate) enum AppEvent {
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
/// background tasks, rollout flush, or child process cleanup).
/// UI exits only after shutdown completes.
Exit(ExitMode),
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
@@ -453,17 +451,11 @@ pub(crate) enum AppEvent {
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
/// already completed (or is being bypassed) and the UI loop should terminate right away.
/// after core acknowledges completion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitMode {
/// Shutdown core and exit after completion.
ShutdownFirst,
/// Exit the UI loop immediately without waiting for shutdown.
///
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
/// cleanup that normally runs before `ShutdownComplete` can be missed.
Immediate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+77 -46
View File
@@ -110,6 +110,7 @@ use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
#[cfg(test)]
use codex_protocol::protocol::BackgroundEventEvent;
#[cfg(test)]
use codex_protocol::protocol::CodexErrorInfo;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
@@ -129,8 +130,10 @@ use codex_protocol::protocol::ImageGenerationBeginEvent;
use codex_protocol::protocol::ImageGenerationEndEvent;
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
use codex_protocol::protocol::McpListToolsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
#[cfg(test)]
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
@@ -149,7 +152,9 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnCompleteEvent;
#[cfg(test)]
use codex_protocol::protocol::TurnDiffEvent;
#[cfg(test)]
use codex_protocol::protocol::UndoCompletedEvent;
#[cfg(test)]
use codex_protocol::protocol::UndoStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::ViewImageToolCallEvent;
@@ -277,6 +282,7 @@ use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::markdown::append_markdown;
#[cfg(test)]
use crate::multi_agents;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
@@ -522,6 +528,7 @@ enum ConnectorsCacheState {
Failed(String),
}
#[cfg(test)]
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
@@ -529,6 +536,7 @@ enum RateLimitErrorKind {
Generic,
}
#[cfg(test)]
fn rate_limit_error_kind(info: &CodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
CodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
@@ -691,6 +699,7 @@ pub(crate) struct ChatWidget {
// Latest completed user-visible Codex output that `/copy` should place on the clipboard.
last_copyable_output: Option<String>,
running_commands: HashMap<String, RunningCommand>,
#[cfg(test)]
pending_collab_spawn_requests: HashMap<String, multi_agents::SpawnRequestSummary>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<AppServerSkillMetadata>,
@@ -1087,6 +1096,7 @@ fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplayKind {
#[cfg(test)]
ResumeInitialMessages,
ThreadSnapshot,
}
@@ -2111,6 +2121,7 @@ impl ChatWidget {
self.maybe_show_pending_rate_limit_prompt();
}
#[cfg(test)]
fn on_server_overloaded_error(&mut self, message: String) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
@@ -2141,6 +2152,7 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
let mut status = self.mcp_startup_status.take().unwrap_or_default();
if let McpStartupStatus::Failed { error } = &ev.status {
@@ -2187,6 +2199,7 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
let mut parts = Vec::new();
if !ev.failed.is_empty() {
@@ -2934,6 +2947,7 @@ impl ChatWidget {
self.had_work_activity = true;
}
#[cfg(test)]
fn on_collab_event(&mut self, cell: PlainHistoryCell) {
self.flush_answer_stream_with_separator();
self.add_to_history(cell);
@@ -2960,10 +2974,6 @@ impl ChatWidget {
self.on_get_history_entry_response(event);
}
fn on_shutdown_complete(&mut self) {
self.request_immediate_exit();
}
fn on_turn_diff(&mut self, unified_diff: String) {
debug!("TurnDiffEvent: {unified_diff}");
self.refresh_status_line();
@@ -2975,6 +2985,7 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_background_event(&mut self, message: String) {
debug!("BackgroundEvent: {message}");
self.bottom_pane.ensure_status_indicator();
@@ -3013,6 +3024,7 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_undo_started(&mut self, event: UndoStartedEvent) {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane.set_interrupt_hint_visible(false);
@@ -3022,6 +3034,7 @@ impl ChatWidget {
self.set_status_header(message);
}
#[cfg(test)]
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
let UndoCompletedEvent { success, message } = event;
self.bottom_pane.hide_status_indicator();
@@ -3655,6 +3668,7 @@ impl ChatWidget {
plan_stream_controller: None,
last_copyable_output: None,
running_commands: HashMap::new(),
#[cfg(test)]
pending_collab_spawn_requests: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
@@ -4878,8 +4892,11 @@ impl ChatWidget {
fn dispatch_thread_update(&mut self, update: ThreadUpdate, replay_kind: Option<ReplayKind>) {
let from_replay = replay_kind.is_some();
#[cfg(test)]
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
#[cfg(not(test))]
let is_resume_initial_replay = false;
if !is_resume_initial_replay {
self.restore_retry_status_header_if_present();
}
@@ -5041,7 +5058,9 @@ impl ChatWidget {
}
}
ThreadUpdate::ThreadRealtimeItemAdded(_) => {}
ThreadUpdate::ThreadRealtimeOutputAudioDelta(_) => {}
ThreadUpdate::ThreadRealtimeOutputAudioDelta(event) => {
self.enqueue_realtime_audio_out(&event.audio.into());
}
ThreadUpdate::ThreadRealtimeError(event) => {
if !from_replay {
self.on_stream_error(event.message, None);
@@ -5145,9 +5164,10 @@ impl ChatWidget {
) {
match item {
codex_app_server_protocol::ThreadItem::UserMessage { content, .. } => {
let compare_key = Self::pending_steer_compare_key_from_api_inputs(&content);
let event = user_message_event_from_api(content);
if from_replay || self.should_render_realtime_user_message_event(&event) {
self.on_user_message_event(event);
self.on_committed_user_message_event(event, compare_key, from_replay);
}
}
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
@@ -5311,8 +5331,11 @@ impl ChatWidget {
replay_kind: Option<ReplayKind>,
) {
let from_replay = replay_kind.is_some();
#[cfg(test)]
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
#[cfg(not(test))]
let is_resume_initial_replay = false;
let is_stream_error = matches!(&msg, EventMsg::StreamError(_));
if !is_resume_initial_replay && !is_stream_error {
self.restore_retry_status_header_if_present();
@@ -5450,7 +5473,7 @@ impl ChatWidget {
force_reload: true,
});
}
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
EventMsg::ShutdownComplete => {}
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
@@ -5548,37 +5571,11 @@ impl ChatWidget {
let EventMsg::UserMessage(event) = item.as_legacy_event() else {
unreachable!("user message item should convert to a legacy user message");
};
let rendered = Self::rendered_user_message_event_from_event(&event);
let compare_key = Self::pending_steer_compare_key_from_item(item);
if self
.pending_steers
.front()
.is_some_and(|pending| pending.compare_key == compare_key)
{
if let Some(pending) = self.pending_steers.pop_front() {
self.refresh_pending_input_preview();
let pending_event = UserMessageEvent {
message: pending.user_message.text,
images: Some(pending.user_message.remote_image_urls),
local_images: pending
.user_message
.local_images
.into_iter()
.map(|image| image.path)
.collect(),
text_elements: pending.user_message.text_elements,
};
self.on_user_message_event(pending_event);
} else if self.last_rendered_user_message_event.as_ref() != Some(&rendered)
{
tracing::warn!(
"pending steer matched compare key but queue was empty when rendering committed user message"
);
self.on_user_message_event(event);
}
} else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) {
self.on_user_message_event(event);
}
self.on_committed_user_message_event(
event,
Self::pending_steer_compare_key_from_items(&item.content),
false,
);
}
if let codex_protocol::items::TurnItem::Plan(plan_item) = &item {
self.on_plan_item_completed(plan_item.text.clone());
@@ -5652,6 +5649,48 @@ impl ChatWidget {
self.request_redraw();
}
fn on_committed_user_message_event(
&mut self,
event: UserMessageEvent,
compare_key: PendingSteerCompareKey,
from_replay: bool,
) {
if from_replay {
self.on_user_message_event(event);
return;
}
let rendered = Self::rendered_user_message_event_from_event(&event);
if self
.pending_steers
.front()
.is_some_and(|pending| pending.compare_key == compare_key)
{
if let Some(pending) = self.pending_steers.pop_front() {
self.refresh_pending_input_preview();
let pending_event = UserMessageEvent {
message: pending.user_message.text,
images: Some(pending.user_message.remote_image_urls),
local_images: pending
.user_message
.local_images
.into_iter()
.map(|image| image.path)
.collect(),
text_elements: pending.user_message.text_elements,
};
self.on_user_message_event(pending_event);
} else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) {
tracing::warn!(
"pending steer matched compare key but queue was empty when rendering committed user message"
);
self.on_user_message_event(event);
}
} else if self.last_rendered_user_message_event.as_ref() != Some(&rendered) {
self.on_user_message_event(event);
}
}
fn on_user_message_event(&mut self, event: UserMessageEvent) {
self.last_rendered_user_message_event =
Some(Self::rendered_user_message_event_from_event(&event));
@@ -5672,14 +5711,6 @@ impl ChatWidget {
self.needs_final_message_separator = false;
}
/// Exit the UI immediately without waiting for shutdown.
///
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
/// this is mainly a fallback for shutdown completion or emergency exits.
fn request_immediate_exit(&self) {
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
}
/// Request a shutdown-first quit.
///
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
+23 -4
View File
@@ -2,8 +2,10 @@ use super::*;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
#[cfg(test)]
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
#[cfg(test)]
use codex_protocol::protocol::RealtimeEvent;
const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely.";
@@ -113,10 +115,26 @@ impl ChatWidget {
}
}
pub(super) fn pending_steer_compare_key_from_item(
item: &codex_protocol::items::UserMessageItem,
pub(super) fn pending_steer_compare_key_from_api_inputs(
items: &[codex_app_server_protocol::UserInput],
) -> PendingSteerCompareKey {
Self::pending_steer_compare_key_from_items(&item.content)
let mut message = String::new();
let mut image_count = 0;
for item in items {
match item {
codex_app_server_protocol::UserInput::Text { text, .. } => message.push_str(text),
codex_app_server_protocol::UserInput::Image { .. }
| codex_app_server_protocol::UserInput::LocalImage { .. } => image_count += 1,
codex_app_server_protocol::UserInput::Skill { .. }
| codex_app_server_protocol::UserInput::Mention { .. } => {}
}
}
PendingSteerCompareKey {
message,
image_count,
}
}
#[cfg(test)]
@@ -256,6 +274,7 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
pub(super) fn on_realtime_conversation_realtime(
&mut self,
ev: RealtimeConversationRealtimeEvent,
@@ -287,7 +306,7 @@ impl ChatWidget {
self.request_redraw();
}
fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) {
pub(super) fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
+43
View File
@@ -4,20 +4,35 @@
//! entries, and the fast-switch keyboard shortcuts. Higher-level coordination, such as deciding
//! which thread becomes active or when a thread closes, stays in [`crate::app::App`].
#[cfg(test)]
use crate::history_cell::PlainHistoryCell;
#[cfg(test)]
use crate::render::line_utils::prefix_lines;
#[cfg(test)]
use crate::text_formatting::truncate_text;
#[cfg(test)]
use codex_protocol::ThreadId;
#[cfg(test)]
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
#[cfg(test)]
use codex_protocol::protocol::AgentStatus;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentRef;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentStatusEntry;
#[cfg(test)]
use codex_protocol::protocol::CollabCloseEndEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabResumeBeginEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabResumeEndEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabWaitingBeginEvent;
#[cfg(test)]
use codex_protocol::protocol::CollabWaitingEndEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -26,13 +41,19 @@ use crossterm::event::KeyEventKind;
#[cfg(target_os = "macos")]
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
#[cfg(test)]
use ratatui::text::Line;
use ratatui::text::Span;
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
use std::collections::HashSet;
#[cfg(test)]
const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160;
#[cfg(test)]
const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160;
#[cfg(test)]
const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -45,6 +66,7 @@ pub(crate) struct AgentPickerThreadEntry {
pub(crate) is_closed: bool,
}
#[cfg(test)]
#[derive(Clone, Copy)]
struct AgentLabel<'a> {
thread_id: Option<ThreadId>,
@@ -52,6 +74,7 @@ struct AgentLabel<'a> {
role: Option<&'a str>,
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SpawnRequestSummary {
pub(crate) model: String,
@@ -171,6 +194,7 @@ fn next_agent_word_motion_fallback(
false
}
#[cfg(test)]
pub(crate) fn spawn_end(
ev: CollabAgentSpawnEndEvent,
spawn_request: Option<&SpawnRequestSummary>,
@@ -206,6 +230,7 @@ pub(crate) fn spawn_end(
collab_event(title, details)
}
#[cfg(test)]
pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell {
let CollabAgentInteractionEndEvent {
call_id: _,
@@ -234,6 +259,7 @@ pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistor
collab_event(title, details)
}
#[cfg(test)]
pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
let CollabWaitingBeginEvent {
sender_thread_id: _,
@@ -261,6 +287,7 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell {
collab_event(title, details)
}
#[cfg(test)]
pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell {
let CollabWaitingEndEvent {
call_id: _,
@@ -272,6 +299,7 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell {
collab_event(title_text("Finished waiting"), details)
}
#[cfg(test)]
pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell {
let CollabCloseEndEvent {
call_id: _,
@@ -296,6 +324,7 @@ pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell {
)
}
#[cfg(test)]
pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell {
let CollabResumeBeginEvent {
call_id: _,
@@ -319,6 +348,7 @@ pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell {
)
}
#[cfg(test)]
pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell {
let CollabResumeEndEvent {
call_id: _,
@@ -343,6 +373,7 @@ pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell {
)
}
#[cfg(test)]
fn collab_event(title: Line<'static>, details: Vec<Line<'static>>) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![title];
if !details.is_empty() {
@@ -351,10 +382,12 @@ fn collab_event(title: Line<'static>, details: Vec<Line<'static>>) -> PlainHisto
PlainHistoryCell::new(lines)
}
#[cfg(test)]
fn title_text(title: impl Into<String>) -> Line<'static> {
title_spans_line(vec![Span::from(title.into()).bold()])
}
#[cfg(test)]
fn title_with_agent(
prefix: &str,
agent: AgentLabel<'_>,
@@ -366,6 +399,7 @@ fn title_with_agent(
title_spans_line(spans)
}
#[cfg(test)]
fn title_spans_line(mut spans: Vec<Span<'static>>) -> Line<'static> {
let mut title = Vec::with_capacity(spans.len() + 1);
title.push(Span::from("• ").dim());
@@ -373,6 +407,7 @@ fn title_spans_line(mut spans: Vec<Span<'static>>) -> Line<'static> {
title.into()
}
#[cfg(test)]
fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> {
AgentLabel {
thread_id: Some(agent.thread_id),
@@ -381,10 +416,12 @@ fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> {
}
}
#[cfg(test)]
fn agent_label_line(agent: AgentLabel<'_>) -> Line<'static> {
agent_label_spans(agent).into()
}
#[cfg(test)]
fn agent_label_spans(agent: AgentLabel<'_>) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let nickname = agent
@@ -409,6 +446,7 @@ fn agent_label_spans(agent: AgentLabel<'_>) -> Vec<Span<'static>> {
spans
}
#[cfg(test)]
fn spawn_request_spans(spawn_request: Option<&SpawnRequestSummary>) -> Vec<Span<'static>> {
let Some(spawn_request) = spawn_request else {
return Vec::new();
@@ -428,6 +466,7 @@ fn spawn_request_spans(spawn_request: Option<&SpawnRequestSummary>) -> Vec<Span<
vec![Span::from(" ").dim(), Span::from(details).magenta()]
}
#[cfg(test)]
fn prompt_line(prompt: &str) -> Option<Line<'static>> {
let trimmed = prompt.trim();
if trimmed.is_empty() {
@@ -440,6 +479,7 @@ fn prompt_line(prompt: &str) -> Option<Line<'static>> {
}
}
#[cfg(test)]
fn merge_wait_receivers(
receiver_thread_ids: &[ThreadId],
mut receiver_agents: Vec<CollabAgentRef>,
@@ -471,6 +511,7 @@ fn merge_wait_receivers(
receiver_agents
}
#[cfg(test)]
fn wait_complete_lines(
statuses: &HashMap<ThreadId, AgentStatus>,
agent_statuses: &[CollabAgentStatusEntry],
@@ -533,10 +574,12 @@ fn wait_complete_lines(
.collect()
}
#[cfg(test)]
fn status_summary_line(status: &AgentStatus) -> Line<'static> {
status_summary_spans(status).into()
}
#[cfg(test)]
fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
match status {
AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
@@ -17,6 +17,8 @@ pub(crate) enum AuthCommand {
StartApiKey { api_key: String },
StartChatgpt,
CancelChatgpt { login_id: String },
ReloadManagedChatgptAuth,
DeviceCodeFailed { message: String },
}
#[derive(Default)]
+74 -37
View File
@@ -2,8 +2,12 @@
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::read_openai_api_key_from_env;
use codex_login::DeviceCode;
use codex_login::ServerOptions;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -24,8 +28,10 @@ use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use tokio::sync::mpsc::UnboundedSender;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -40,6 +46,8 @@ use super::account_login::AuthCommand;
use super::account_login::login_status_from_account;
use super::onboarding_screen::StepState;
mod headless_chatgpt_login;
/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.
///
/// Terminal emulators recognise the OSC 8 escape sequence and treat the entire
@@ -78,6 +86,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage,
ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState),
@@ -92,7 +101,6 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const DEVICE_CODE_UNAVAILABLE_MESSAGE: &str = "Device code sign-in is not yet available through the app-server onboarding flow. Use browser sign-in instead.";
#[derive(Clone, Default)]
pub(crate) struct ApiKeyInputState {
@@ -106,6 +114,12 @@ pub(crate) struct ContinueInBrowserState {
login_id: Option<String>,
}
#[derive(Clone)]
pub(crate) struct ContinueWithDeviceCodeState {
device_code: Option<DeviceCode>,
cancel: Option<Arc<Notify>>,
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_api_key_entry_key_event(&key_event) {
@@ -143,15 +157,26 @@ impl KeyboardHandler for AuthModeWidget {
KeyCode::Esc => {
tracing::info!("Esc pressed");
let mut sign_in_state = self.sign_in_state.write().unwrap();
if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state {
if let Some(login_id) = state.login_id.clone() {
let _ = self
.auth_command_tx
.send(AuthCommand::CancelChatgpt { login_id });
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(state) => {
if let Some(login_id) = state.login_id.clone() {
let _ = self
.auth_command_tx
.send(AuthCommand::CancelChatgpt { login_id });
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
_ => {}
}
}
_ => {}
@@ -170,7 +195,10 @@ pub(crate) struct AuthModeWidget {
pub highlighted_mode: SignInOption,
pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
@@ -238,7 +266,7 @@ impl AuthModeWidget {
}
SignInOption::DeviceCode => {
if self.is_chatgpt_login_allowed() {
self.show_device_code_login_todo();
self.start_device_code_login();
}
}
SignInOption::ApiKey => {
@@ -258,14 +286,6 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
fn show_device_code_login_todo(&mut self) {
// TODO: Restore device-code onboarding once app-server exposes a typed
// login flow for it.
self.error = Some(DEVICE_CODE_UNAVAILABLE_MESSAGE.to_string());
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
}
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
@@ -681,6 +701,21 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
fn start_device_code_login(&mut self) {
if self.handle_existing_chatgpt_login() {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
}
pub(crate) fn apply_account(&mut self, account: Option<&Account>) {
self.login_status = login_status_from_account(account);
}
@@ -752,6 +787,12 @@ impl AuthModeWidget {
self.request_frame.schedule_frame();
}
pub(crate) fn show_device_code_login_error(&mut self, message: String) {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(message);
self.request_frame.schedule_frame();
}
pub(crate) fn show_api_key_login_error(&mut self, message: String) {
self.error = Some(message);
self.request_frame.schedule_frame();
@@ -765,6 +806,7 @@ impl StepStateProvider for AuthModeWidget {
SignInState::PickMode
| SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptDeviceCode(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
}
@@ -781,6 +823,9 @@ impl WidgetRef for AuthModeWidget {
SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf);
}
SignInState::ChatGptDeviceCode(state) => {
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
}
SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf);
}
@@ -814,7 +859,10 @@ mod tests {
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: PathBuf::from("/tmp"),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
login_status: LoginStatus::NotAuthenticated,
forced_chatgpt_workspace_id: None,
forced_login_method,
animations_enabled: true,
}
@@ -847,22 +895,6 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[test]
fn device_code_login_shows_todo_error() {
let mut widget = auth_widget(None, SignInOption::DeviceCode);
widget.handle_sign_in_option(SignInOption::DeviceCode);
assert_eq!(
widget.error.as_deref(),
Some(DEVICE_CODE_UNAVAILABLE_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
let open = format!("\x1B]8;;{url}\x07");
let close = "\x1B]8;;\x07";
@@ -899,15 +931,20 @@ mod tests {
}
#[test]
fn device_code_login_unavailable_snapshot() {
fn device_code_login_pending_snapshot() {
let mut widget = auth_widget(None, SignInOption::DeviceCode);
widget.handle_sign_in_option(SignInOption::DeviceCode);
widget.animations_enabled = false;
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(Arc::new(Notify::new())),
});
let area = Rect::new(0, 0, 70, 18);
let mut buf = Buffer::empty(area);
widget.render_ref(area, &mut buf);
assert_snapshot!("device_code_login_unavailable", format!("{buf:?}"));
assert_snapshot!("device_code_login_pending", format!("{buf:?}"));
}
#[test]
@@ -0,0 +1,281 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::AuthModeWidget;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
use crate::onboarding::account_login::AuthCommand;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let auth_command_tx = widget.auth_command_tx.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
let device_code = match request_device_code(&opts).await {
Ok(device_code) => device_code,
Err(err) => {
if set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
) {
let _ = auth_command_tx.send(AuthCommand::DeviceCodeFailed {
message: err.to_string(),
});
}
return;
}
};
if !set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: Some(device_code.clone()),
cancel: Some(cancel.clone()),
}),
) {
return;
}
tokio::select! {
_ = cancel.notified() => {}
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
if set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptSuccessMessage,
) {
let _ = auth_command_tx.send(AuthCommand::ReloadManagedChatgptAuth);
}
}
Err(err) => {
if set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
) {
let _ = auth_command_tx.send(AuthCommand::DeviceCodeFailed {
message: err.to_string(),
});
}
}
}
}
}
});
}
pub(super) fn render_device_code_login(
widget: &AuthModeWidget,
area: Rect,
buf: &mut Buffer,
state: &ContinueWithDeviceCodeState,
) {
let banner = if state.device_code.is_some() {
"Finish signing in via your browser"
} else {
"Preparing device code login"
};
let mut spans = vec![" ".into()];
if widget.animations_enabled {
widget
.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans(banner));
} else {
spans.push(banner.into());
}
let mut lines = vec![spans.into(), "".into()];
let verification_url = if let Some(device_code) = &state.device_code {
lines.push(" 1. Open this link in your browser and sign in".into());
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
device_code.verification_url.as_str().cyan().underlined(),
]));
lines.push("".into());
lines.push(
" 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(),
);
lines.push("".into());
lines.push(Line::from(vec![
" ".into(),
device_code.user_code.as_str().cyan().bold(),
]));
lines.push("".into());
lines.push(
" Device codes are a common phishing target. Never share this code."
.dim()
.into(),
);
lines.push("".into());
Some(device_code.verification_url.clone())
} else {
lines.push(" Requesting a one-time code...".dim().into());
lines.push("".into());
None
};
lines.push(" Press Esc to cancel".dim().into());
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
if let Some(url) = &verification_url {
mark_url_hyperlink(buf, area, url);
}
}
fn device_code_attempt_matches(state: &SignInState, cancel: &Arc<Notify>) -> bool {
matches!(
state,
SignInState::ChatGptDeviceCode(state)
if state
.cancel
.as_ref()
.is_some_and(|existing| Arc::ptr_eq(existing, cancel))
)
}
fn begin_device_code_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
) -> Arc<Notify> {
let cancel = Arc::new(Notify::new());
*sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
request_frame.schedule_frame();
cancel
}
fn set_device_code_state_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
cancel: &Arc<Notify>,
next_state: SignInState,
) -> bool {
let mut guard = sign_in_state.write().unwrap();
if !device_code_attempt_matches(&guard, cancel) {
return false;
}
*guard = next_state;
drop(guard);
request_frame.schedule_frame();
true
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel),
},
)))
}
#[test]
fn device_code_attempt_matches_only_for_matching_cancel() {
let cancel = Arc::new(Notify::new());
let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
assert_eq!(device_code_attempt_matches(&state, &cancel), true);
assert_eq!(
device_code_attempt_matches(&state, &Arc::new(Notify::new())),
false
);
assert_eq!(
device_code_attempt_matches(&SignInState::PickMode, &cancel),
false
);
}
#[test]
fn begin_device_code_attempt_sets_state() {
let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode));
let request_frame = FrameRequester::test_dummy();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
let guard = sign_in_state.read().unwrap();
let state: &SignInState = &guard;
assert_eq!(device_code_attempt_matches(state, &cancel), true);
assert!(matches!(
state,
SignInState::ChatGptDeviceCode(state) if state.device_code.is_none()
));
}
#[test]
fn set_device_code_state_for_active_attempt_updates_only_when_active() {
let request_frame = FrameRequester::test_dummy();
let cancel = Arc::new(Notify::new());
let sign_in_state = device_code_sign_in_state(cancel.clone());
assert_eq!(
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
),
true
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::PickMode
));
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
assert_eq!(
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
),
false
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptDeviceCode(_)
));
}
}
@@ -114,7 +114,10 @@ impl OnboardingScreen {
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: config.codex_home.clone(),
cli_auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
login_status,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
forced_login_method,
animations_enabled: config.animations,
}))
@@ -568,6 +571,24 @@ async fn handle_auth_command(
tracing::warn!("failed to cancel onboarding login: {err}");
}
}
AuthCommand::ReloadManagedChatgptAuth => {
app_server.reload_auth_from_storage();
match account_api.read_account(app_server).await {
Ok(response) => {
if let Some(auth_widget) = onboarding_screen.auth_widget_mut() {
auth_widget.apply_account(response.account.as_ref());
}
}
Err(err) => {
tracing::warn!("failed to refresh managed auth after device code login: {err}")
}
}
}
AuthCommand::DeviceCodeFailed { message } => {
if let Some(auth_widget) = onboarding_screen.auth_widget_mut() {
auth_widget.show_device_code_login_error(message);
}
}
}
}
@@ -0,0 +1,34 @@
---
source: tui/src/onboarding/auth.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 70, height: 18 },
content: [
" Preparing device code login ",
" ",
" Requesting a one-time code... ",
" ",
" Press Esc to cancel ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 31, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 21, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,45 +0,0 @@
---
source: tui/src/onboarding/auth.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 70, height: 18 },
content: [
" Sign in with ChatGPT to use Codex as part of your paid plan ",
" or connect an API key for usage-based billing ",
" ",
" 1. Sign in with ChatGPT ",
" Usage included with Plus, Pro, Business, and Enterprise plans ",
" ",
"> 2. Sign in with Device Code ",
" Sign in from another device with a one-time code ",
" ",
" 3. Provide your own API key ",
" Pay for what you use ",
" ",
" Press Enter to continue ",
" ",
"Device code sign-in is not yet available through the app-server ",
"onboarding flow. Use browser sign-in instead. ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 66, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM,
x: 5, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 25, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 25, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 14, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 63, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 15, fg: Red, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}