codex_cli/prompt_segment/
mod.rs1use 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}