Skip to main content

codex_cli/prompt_segment/
mod.rs

1use std::path::Path;
2
3use crate::auth;
4use crate::auth::status::ActiveAuthStatus;
5use crate::diag_output;
6use crate::rate_limits::cache;
7use nils_common::env as shared_env;
8use serde::Serialize;
9
10mod lock;
11mod refresh;
12mod render;
13
14pub use render::CacheEntry;
15
16pub struct PromptSegmentOptions {
17    pub no_5h: bool,
18    pub ttl: Option<String>,
19    pub time_format: Option<String>,
20    pub show_timezone: bool,
21    pub refresh: bool,
22}
23
24const DEFAULT_TTL_SECONDS: u64 = 180;
25const DEFAULT_TIME_FORMAT: &str = "%m-%d %H:%M";
26const DEFAULT_TIME_FORMAT_WITH_TIMEZONE: &str = "%m-%d %H:%M %:z";
27const PROMPT_SEGMENT_SCHEMA_VERSION: &str = "codex-cli.prompt-segment.v1";
28
29pub fn run(options: &PromptSegmentOptions) -> i32 {
30    let ttl_seconds = match resolve_ttl_seconds(options.ttl.as_deref()) {
31        Ok(value) => value,
32        Err(_) => {
33            print_ttl_usage();
34            return 2;
35        }
36    };
37
38    if !prompt_segment_enabled() {
39        return 0;
40    }
41
42    let auth_status = auth::status::inspect_active_auth();
43    if !auth_status.prompt_segment_authenticated {
44        return 0;
45    }
46
47    let target_file = match auth_status.auth_file {
48        Some(path) => path,
49        None => return 0,
50    };
51
52    let show_5h =
53        shared_env::env_truthy_or("CODEX_PROMPT_SEGMENT_SHOW_5H_ENABLED", true) && !options.no_5h;
54    let time_format = match options.time_format.as_deref() {
55        Some(value) => value,
56        None if options.show_timezone => DEFAULT_TIME_FORMAT_WITH_TIMEZONE,
57        None => DEFAULT_TIME_FORMAT,
58    };
59    let stale_suffix = std::env::var("CODEX_PROMPT_SEGMENT_STALE_SUFFIX")
60        .unwrap_or_else(|_| " (stale)".to_string());
61
62    let prefix = resolve_name_prefix(&target_file);
63
64    if options.refresh {
65        if let Some(entry) = refresh::refresh_blocking(&target_file)
66            && let Some(line) = render::render_line(&entry, &prefix, show_5h, time_format)
67            && !line.trim().is_empty()
68        {
69            let line = apply_prompt_escape(line);
70            println!("{line}");
71        }
72        return 0;
73    }
74
75    let (cached, is_stale) = read_cached_entry(&target_file, ttl_seconds);
76    if let Some(entry) = cached.clone()
77        && let Some(mut line) = render::render_line(&entry, &prefix, show_5h, time_format)
78    {
79        if is_stale {
80            line.push_str(&stale_suffix);
81        }
82        if !line.trim().is_empty() {
83            let line = apply_prompt_escape(line);
84            println!("{line}");
85        }
86    }
87
88    if cached.is_none() || is_stale {
89        refresh::enqueue_background_refresh(&target_file);
90    }
91
92    0
93}
94
95pub fn check() -> i32 {
96    if prompt_segment_enabled() && auth::status::inspect_active_auth().prompt_segment_authenticated
97    {
98        0
99    } else {
100        1
101    }
102}
103
104pub fn status(output_json: bool) -> i32 {
105    let enabled = prompt_segment_enabled();
106    let auth_status = auth::status::inspect_active_auth();
107    let ttl_seconds = resolve_ttl_seconds(None).unwrap_or(DEFAULT_TTL_SECONDS);
108    let result = PromptSegmentStatusResult::from_state(enabled, &auth_status, ttl_seconds);
109
110    if output_json {
111        if diag_output::emit_success_result(
112            PROMPT_SEGMENT_SCHEMA_VERSION,
113            "prompt-segment status",
114            &result,
115        )
116        .is_err()
117        {
118            return 1;
119        }
120    } else {
121        println!(
122            "codex: prompt-segment status enabled={} authenticated={} would_render={} reason={}",
123            result.enabled, result.prompt_segment_authenticated, result.would_render, result.reason
124        );
125    }
126
127    0
128}
129
130fn prompt_segment_enabled() -> bool {
131    shared_env::env_truthy("CODEX_PROMPT_SEGMENT_ENABLED")
132}
133
134fn apply_prompt_escape(line: String) -> String {
135    if shared_env::env_truthy("CODEX_PROMPT_SEGMENT_ZSH_ESCAPE_ENABLED") {
136        return escape_zsh_prompt_percent(&line);
137    }
138    line
139}
140
141fn escape_zsh_prompt_percent(line: &str) -> String {
142    line.replace('%', "%%")
143}
144
145fn resolve_ttl_seconds(cli_ttl: Option<&str>) -> Result<u64, ()> {
146    if let Some(raw) = cli_ttl {
147        return shared_env::parse_duration_seconds(raw).ok_or(());
148    }
149
150    if let Ok(raw) = std::env::var("CODEX_PROMPT_SEGMENT_TTL")
151        && let Some(value) = shared_env::parse_duration_seconds(&raw)
152    {
153        return Ok(value);
154    }
155
156    Ok(DEFAULT_TTL_SECONDS)
157}
158
159fn print_ttl_usage() {
160    eprintln!("codex-cli prompt-segment: invalid --ttl");
161    eprintln!(
162        "usage: codex-cli prompt-segment [--no-5h] [--ttl <duration>] [--time-format <strftime>] [--show-timezone] [--refresh]"
163    );
164}
165
166fn read_cached_entry(target_file: &Path, ttl_seconds: u64) -> (Option<CacheEntry>, bool) {
167    let cache_file = match cache::cache_file_for_target(target_file) {
168        Ok(value) => value,
169        Err(_) => return (None, false),
170    };
171    if !cache_file.is_file() {
172        return (None, false);
173    }
174
175    let entry = render::read_cache_file(&cache_file);
176    let Some(entry) = entry else {
177        return (None, false);
178    };
179
180    let now_epoch = chrono::Utc::now().timestamp();
181    if now_epoch <= 0 || entry.fetched_at_epoch <= 0 {
182        return (Some(entry), true);
183    }
184
185    let ttl_i64 = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
186    let stale = now_epoch.saturating_sub(entry.fetched_at_epoch) > ttl_i64;
187    (Some(entry), stale)
188}
189
190fn resolve_name_prefix(target_file: &Path) -> String {
191    let name = resolve_name(target_file);
192    match name {
193        Some(value) if !value.trim().is_empty() => format!("{} ", value.trim()),
194        _ => String::new(),
195    }
196}
197
198fn resolve_name(target_file: &Path) -> Option<String> {
199    let name_source = std::env::var("CODEX_PROMPT_SEGMENT_NAME_SOURCE")
200        .ok()
201        .map(|value| value.to_ascii_lowercase())
202        .unwrap_or_else(|| "secret".to_string());
203
204    let show_fallback = shared_env::env_truthy("CODEX_PROMPT_SEGMENT_SHOW_FALLBACK_NAME_ENABLED");
205    let show_full_email = shared_env::env_truthy("CODEX_PROMPT_SEGMENT_SHOW_FULL_EMAIL_ENABLED");
206
207    if name_source == "email" {
208        if let Ok(Some(email)) = auth::email_from_auth_file(target_file) {
209            return Some(format_email_name(&email, show_full_email));
210        }
211        if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
212            return Some(format_email_name(&identity, show_full_email));
213        }
214        return None;
215    }
216
217    if let Some(secret_name) = cache::secret_name_for_target(target_file) {
218        return Some(secret_name);
219    }
220
221    if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
222        return Some(format_email_name(&identity, show_full_email));
223    }
224
225    None
226}
227
228fn format_email_name(raw: &str, show_full_email: bool) -> String {
229    let trimmed = raw.trim();
230    if show_full_email {
231        return trimmed.to_string();
232    }
233    trimmed.split('@').next().unwrap_or(trimmed).to_string()
234}
235
236#[derive(Debug, Clone, Serialize)]
237struct PromptSegmentStatusResult {
238    enabled: bool,
239    authenticated: bool,
240    prompt_segment_authenticated: bool,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    auth_file: Option<String>,
243    auth_reason: String,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    cache_file: Option<String>,
246    cache_exists: bool,
247    cache_stale: bool,
248    would_render: bool,
249    reason: String,
250}
251
252impl PromptSegmentStatusResult {
253    fn from_state(enabled: bool, auth_status: &ActiveAuthStatus, ttl_seconds: u64) -> Self {
254        let mut cache_file = None;
255        let mut cache_exists = false;
256        let mut cache_stale = false;
257        let mut would_render = false;
258
259        if enabled
260            && auth_status.prompt_segment_authenticated
261            && let Some(target_file) = auth_status.auth_file.as_deref()
262        {
263            cache_file = cache::cache_file_for_target(target_file)
264                .ok()
265                .map(|path| path.display().to_string());
266            if let Some(path) = cache_file.as_deref() {
267                cache_exists = Path::new(path).is_file();
268            }
269            let (cached, stale) = read_cached_entry(target_file, ttl_seconds);
270            cache_stale = stale;
271            would_render = cached
272                .as_ref()
273                .and_then(|entry| {
274                    render::render_line(
275                        entry,
276                        &resolve_name_prefix(target_file),
277                        shared_env::env_truthy_or("CODEX_PROMPT_SEGMENT_SHOW_5H_ENABLED", true),
278                        DEFAULT_TIME_FORMAT,
279                    )
280                })
281                .map(|line| !line.trim().is_empty())
282                .unwrap_or(false);
283        }
284
285        let reason = if !enabled {
286            "disabled"
287        } else if auth_status.authenticated
288            && !auth_status.prompt_segment_authenticated
289            && !auth_status.has_oauth_access_token
290        {
291            "access-token-missing"
292        } else if !auth_status.prompt_segment_authenticated {
293            auth_status.reason.as_str()
294        } else if would_render {
295            "ready"
296        } else if !cache_exists {
297            "cache-missing"
298        } else {
299            "cache-empty-or-invalid"
300        };
301
302        Self {
303            enabled,
304            authenticated: auth_status.authenticated,
305            prompt_segment_authenticated: auth_status.prompt_segment_authenticated,
306            auth_file: auth_status
307                .auth_file
308                .as_ref()
309                .map(|path| path.display().to_string()),
310            auth_reason: auth_status.reason.as_str().to_string(),
311            cache_file,
312            cache_exists,
313            cache_stale,
314            would_render,
315            reason: reason.to_string(),
316        }
317    }
318}