mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Part 10
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -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,
|
||||
]
|
||||
}
|
||||
-45
@@ -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,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user