codex_cli/prompt_segment/
mod.rs1use std::path::Path;
2
3use crate::auth;
4use crate::paths;
5use crate::rate_limits::cache;
6use nils_common::env as shared_env;
7
8mod lock;
9mod refresh;
10mod render;
11
12pub use render::CacheEntry;
13
14pub struct PromptSegmentOptions {
15 pub no_5h: bool,
16 pub ttl: Option<String>,
17 pub time_format: Option<String>,
18 pub show_timezone: bool,
19 pub refresh: bool,
20 pub is_enabled: bool,
21}
22
23const DEFAULT_TTL_SECONDS: u64 = 180;
24const DEFAULT_TIME_FORMAT: &str = "%m-%d %H:%M";
25const DEFAULT_TIME_FORMAT_WITH_TIMEZONE: &str = "%m-%d %H:%M %:z";
26
27pub fn run(options: &PromptSegmentOptions) -> i32 {
28 if options.is_enabled {
29 return if prompt_segment_enabled() { 0 } else { 1 };
30 }
31
32 let ttl_seconds = match resolve_ttl_seconds(options.ttl.as_deref()) {
33 Ok(value) => value,
34 Err(_) => {
35 print_ttl_usage();
36 return 2;
37 }
38 };
39
40 if !prompt_segment_enabled() {
41 return 0;
42 }
43
44 let target_file = match paths::resolve_auth_file() {
45 Some(path) => path,
46 None => return 0,
47 };
48
49 let show_5h =
50 shared_env::env_truthy_or("CODEX_PROMPT_SEGMENT_SHOW_5H_ENABLED", true) && !options.no_5h;
51 let time_format = match options.time_format.as_deref() {
52 Some(value) => value,
53 None if options.show_timezone => DEFAULT_TIME_FORMAT_WITH_TIMEZONE,
54 None => DEFAULT_TIME_FORMAT,
55 };
56 let stale_suffix = std::env::var("CODEX_PROMPT_SEGMENT_STALE_SUFFIX")
57 .unwrap_or_else(|_| " (stale)".to_string());
58
59 let prefix = resolve_name_prefix(&target_file);
60
61 if options.refresh {
62 if let Some(entry) = refresh::refresh_blocking(&target_file)
63 && let Some(line) = render::render_line(&entry, &prefix, show_5h, time_format)
64 && !line.trim().is_empty()
65 {
66 println!("{line}");
67 }
68 return 0;
69 }
70
71 let (cached, is_stale) = read_cached_entry(&target_file, ttl_seconds);
72 if let Some(entry) = cached.clone()
73 && let Some(mut line) = render::render_line(&entry, &prefix, show_5h, time_format)
74 {
75 if is_stale {
76 line.push_str(&stale_suffix);
77 }
78 if !line.trim().is_empty() {
79 println!("{line}");
80 }
81 }
82
83 if cached.is_none() || is_stale {
84 refresh::enqueue_background_refresh(&target_file);
85 }
86
87 0
88}
89
90fn prompt_segment_enabled() -> bool {
91 shared_env::env_truthy("CODEX_PROMPT_SEGMENT_ENABLED")
92}
93
94fn resolve_ttl_seconds(cli_ttl: Option<&str>) -> Result<u64, ()> {
95 if let Some(raw) = cli_ttl {
96 return shared_env::parse_duration_seconds(raw).ok_or(());
97 }
98
99 if let Ok(raw) = std::env::var("CODEX_PROMPT_SEGMENT_TTL")
100 && let Some(value) = shared_env::parse_duration_seconds(&raw)
101 {
102 return Ok(value);
103 }
104
105 Ok(DEFAULT_TTL_SECONDS)
106}
107
108fn print_ttl_usage() {
109 eprintln!("codex-cli prompt-segment: invalid --ttl");
110 eprintln!(
111 "usage: codex-cli prompt-segment [--no-5h] [--ttl <duration>] [--time-format <strftime>] [--show-timezone] [--refresh] [--is-enabled]"
112 );
113}
114
115fn read_cached_entry(target_file: &Path, ttl_seconds: u64) -> (Option<CacheEntry>, bool) {
116 let cache_file = match cache::cache_file_for_target(target_file) {
117 Ok(value) => value,
118 Err(_) => return (None, false),
119 };
120 if !cache_file.is_file() {
121 return (None, false);
122 }
123
124 let entry = render::read_cache_file(&cache_file);
125 let Some(entry) = entry else {
126 return (None, false);
127 };
128
129 let now_epoch = chrono::Utc::now().timestamp();
130 if now_epoch <= 0 || entry.fetched_at_epoch <= 0 {
131 return (Some(entry), true);
132 }
133
134 let ttl_i64 = i64::try_from(ttl_seconds).unwrap_or(i64::MAX);
135 let stale = now_epoch.saturating_sub(entry.fetched_at_epoch) > ttl_i64;
136 (Some(entry), stale)
137}
138
139fn resolve_name_prefix(target_file: &Path) -> String {
140 let name = resolve_name(target_file);
141 match name {
142 Some(value) if !value.trim().is_empty() => format!("{} ", value.trim()),
143 _ => String::new(),
144 }
145}
146
147fn resolve_name(target_file: &Path) -> Option<String> {
148 let name_source = std::env::var("CODEX_PROMPT_SEGMENT_NAME_SOURCE")
149 .ok()
150 .map(|value| value.to_ascii_lowercase())
151 .unwrap_or_else(|| "secret".to_string());
152
153 let show_fallback = shared_env::env_truthy("CODEX_PROMPT_SEGMENT_SHOW_FALLBACK_NAME_ENABLED");
154 let show_full_email = shared_env::env_truthy("CODEX_PROMPT_SEGMENT_SHOW_FULL_EMAIL_ENABLED");
155
156 if name_source == "email" {
157 if let Ok(Some(email)) = auth::email_from_auth_file(target_file) {
158 return Some(format_email_name(&email, show_full_email));
159 }
160 if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
161 return Some(format_email_name(&identity, show_full_email));
162 }
163 return None;
164 }
165
166 if let Some(secret_name) = cache::secret_name_for_target(target_file) {
167 return Some(secret_name);
168 }
169
170 if show_fallback && let Ok(Some(identity)) = auth::identity_from_auth_file(target_file) {
171 return Some(format_email_name(&identity, show_full_email));
172 }
173
174 None
175}
176
177fn format_email_name(raw: &str, show_full_email: bool) -> String {
178 let trimmed = raw.trim();
179 if show_full_email {
180 return trimmed.to_string();
181 }
182 trimmed.split('@').next().unwrap_or(trimmed).to_string()
183}