mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
8e69d29521
## Why The TUI still reached through `app-server-client::legacy_core` for thread-name normalization and project-instruction filename details. In particular, checking the TUI's local filesystem for `/init` is incorrect for remote app-server sessions, where the server owns the working directory and instruction discovery. ## What changed - use the instruction source paths supplied by the app server to decide whether `/init` should avoid overwriting project instructions - keep the small thread-name normalization helper local to the TUI - remove the now-unused instruction filename constants, utility module, and other unused `legacy_core` re-exports - make status helper tests independent of concrete instruction filenames ## Verification - `just test -p codex-app-server-client` - `just test -p codex-tui slash_init_skips_when_project_instructions_are_loaded` - `just test -p codex-tui` ran 2,799 tests; 2,797 passed and two unrelated guardian feature-flag tests failed reproducibly in untouched code ### Manual test Started an app server over WebSocket with a remote workspace containing `AGENTS.md`, then connected the TUI using `--remote`. After confirming `thread/start` returned the file in `instructionSources`, deleted `AGENTS.md` and ran `/init` in the existing session. The TUI still reported that project instructions already existed and skipped `/init`. The trace contained no `turn/start` request, confirming the decision came from app-server session state rather than a new client-local filesystem check.
275 lines
8.7 KiB
Rust
275 lines
8.7 KiB
Rust
use crate::exec_command::relativize_to_home;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::status::StatusAccountDisplay;
|
|
use crate::text_formatting;
|
|
use chrono::DateTime;
|
|
use chrono::Local;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use std::path::Path;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
fn normalize_agents_display_path(path: &Path) -> String {
|
|
dunce::simplified(path).display().to_string()
|
|
}
|
|
|
|
pub(crate) fn compose_model_display(
|
|
model_name: &str,
|
|
entries: &[(&str, String)],
|
|
) -> (String, Vec<String>) {
|
|
let mut details: Vec<String> = Vec::new();
|
|
if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") {
|
|
details.push(format!("reasoning {}", effort.to_ascii_lowercase()));
|
|
}
|
|
if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") {
|
|
let summary = summary.trim();
|
|
if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") {
|
|
details.push("summaries off".to_string());
|
|
} else if !summary.is_empty() {
|
|
details.push(format!("summaries {}", summary.to_ascii_lowercase()));
|
|
}
|
|
}
|
|
|
|
(model_name.to_string(), details)
|
|
}
|
|
|
|
pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String {
|
|
let mut rels: Vec<String> = Vec::new();
|
|
|
|
for p in paths {
|
|
let p = p.as_path();
|
|
let file_name = p
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "<unknown>".to_string());
|
|
let display = if let Some(parent) = p.parent() {
|
|
if parent == config.cwd.as_path() {
|
|
file_name.clone()
|
|
} else {
|
|
let mut cur = config.cwd.as_path();
|
|
let mut ups = 0usize;
|
|
let mut reached = false;
|
|
while let Some(c) = cur.parent() {
|
|
if cur == parent {
|
|
reached = true;
|
|
break;
|
|
}
|
|
cur = c;
|
|
ups += 1;
|
|
}
|
|
if reached {
|
|
let up = format!("..{}", std::path::MAIN_SEPARATOR);
|
|
format!("{}{}", up.repeat(ups), file_name)
|
|
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
|
|
normalize_agents_display_path(stripped)
|
|
} else {
|
|
normalize_agents_display_path(p)
|
|
}
|
|
}
|
|
} else {
|
|
normalize_agents_display_path(p)
|
|
};
|
|
rels.push(display);
|
|
}
|
|
|
|
if rels.is_empty() {
|
|
"<none>".to_string()
|
|
} else {
|
|
rels.join(", ")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn compose_account_display(
|
|
account_display: Option<&StatusAccountDisplay>,
|
|
) -> Option<StatusAccountDisplay> {
|
|
account_display.cloned()
|
|
}
|
|
|
|
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
|
|
if plan_type.is_team_like() {
|
|
"Business".to_string()
|
|
} else if plan_type.is_business_like() {
|
|
"Enterprise".to_string()
|
|
} else if plan_type == PlanType::ProLite {
|
|
"Pro Lite".to_string()
|
|
} else {
|
|
title_case(format!("{plan_type:?}").as_str())
|
|
}
|
|
}
|
|
|
|
pub(crate) fn format_tokens_compact(value: i64) -> String {
|
|
let value = value.max(0);
|
|
if value == 0 {
|
|
return "0".to_string();
|
|
}
|
|
if value < 1_000 {
|
|
return value.to_string();
|
|
}
|
|
|
|
let value_f64 = value as f64;
|
|
let (scaled, suffix) = if value >= 1_000_000_000_000 {
|
|
(value_f64 / 1_000_000_000_000.0, "T")
|
|
} else if value >= 1_000_000_000 {
|
|
(value_f64 / 1_000_000_000.0, "B")
|
|
} else if value >= 1_000_000 {
|
|
(value_f64 / 1_000_000.0, "M")
|
|
} else {
|
|
(value_f64 / 1_000.0, "K")
|
|
};
|
|
|
|
let decimals = if scaled < 10.0 {
|
|
2
|
|
} else if scaled < 100.0 {
|
|
1
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let mut formatted = format!("{scaled:.decimals$}");
|
|
if formatted.contains('.') {
|
|
while formatted.ends_with('0') {
|
|
formatted.pop();
|
|
}
|
|
if formatted.ends_with('.') {
|
|
formatted.pop();
|
|
}
|
|
}
|
|
|
|
format!("{formatted}{suffix}")
|
|
}
|
|
|
|
pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize>) -> String {
|
|
let formatted = if let Some(rel) = relativize_to_home(directory) {
|
|
if rel.as_os_str().is_empty() {
|
|
"~".to_string()
|
|
} else {
|
|
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
|
|
}
|
|
} else {
|
|
directory.display().to_string()
|
|
};
|
|
|
|
if let Some(max_width) = max_width {
|
|
if max_width == 0 {
|
|
return String::new();
|
|
}
|
|
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
|
|
return text_formatting::center_truncate_path(&formatted, max_width);
|
|
}
|
|
}
|
|
|
|
formatted
|
|
}
|
|
|
|
pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<Local>) -> String {
|
|
let time = dt.format("%H:%M").to_string();
|
|
if dt.date_naive() == captured_at.date_naive() {
|
|
time
|
|
} else {
|
|
format!("{time} on {}", dt.format("%-d %b"))
|
|
}
|
|
}
|
|
|
|
fn title_case(s: &str) -> String {
|
|
if s.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut chars = s.chars();
|
|
let Some(first) = chars.next() else {
|
|
return String::new();
|
|
};
|
|
let rest = chars.as_str().to_ascii_lowercase();
|
|
first.to_uppercase().collect::<String>() + &rest
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
|
|
async fn test_config(codex_home: &TempDir, cwd: &TempDir) -> Config {
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.fallback_cwd(Some(cwd.path().to_path_buf()))
|
|
.build()
|
|
.await
|
|
.expect("load config")
|
|
}
|
|
|
|
#[test]
|
|
fn plan_type_display_name_remaps_display_labels() {
|
|
let cases = [
|
|
(PlanType::Free, "Free"),
|
|
(PlanType::Go, "Go"),
|
|
(PlanType::Plus, "Plus"),
|
|
(PlanType::Pro, "Pro"),
|
|
(PlanType::ProLite, "Pro Lite"),
|
|
(PlanType::Team, "Business"),
|
|
(PlanType::SelfServeBusinessUsageBased, "Business"),
|
|
(PlanType::Business, "Enterprise"),
|
|
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
|
|
(PlanType::Enterprise, "Enterprise"),
|
|
(PlanType::Edu, "Edu"),
|
|
(PlanType::Unknown, "Unknown"),
|
|
];
|
|
|
|
for (plan_type, expected) in cases {
|
|
assert_eq!(plan_type_display_name(plan_type), expected);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compose_agents_summary_includes_global_agents_path() {
|
|
let codex_home = TempDir::new().expect("temp codex home");
|
|
let cwd = TempDir::new().expect("temp cwd");
|
|
let global_agents_path = codex_home.path().join("global.md");
|
|
let config = test_config(&codex_home, &cwd).await;
|
|
|
|
assert_eq!(
|
|
compose_agents_summary(&config, &[global_agents_path.abs()]),
|
|
format_directory_display(&global_agents_path, /*max_width*/ None)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compose_agents_summary_names_global_agents_override() {
|
|
let codex_home = TempDir::new().expect("temp codex home");
|
|
let cwd = TempDir::new().expect("temp cwd");
|
|
let override_path = codex_home.path().join("override.md");
|
|
let config = test_config(&codex_home, &cwd).await;
|
|
|
|
assert_eq!(
|
|
compose_agents_summary(&config, &[override_path.abs()]),
|
|
format_directory_display(&override_path, /*max_width*/ None)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compose_agents_summary_orders_global_before_project_agents() {
|
|
let codex_home = TempDir::new().expect("temp codex home");
|
|
let cwd = TempDir::new().expect("temp cwd");
|
|
let global_agents_path = codex_home.path().join("global.md");
|
|
let project_agents_path = cwd.path().join("project.md");
|
|
let config = test_config(&codex_home, &cwd).await;
|
|
|
|
let summary = compose_agents_summary(
|
|
&config,
|
|
&[
|
|
global_agents_path.clone().abs(),
|
|
project_agents_path.clone().abs(),
|
|
],
|
|
);
|
|
let mut paths = summary.split(", ");
|
|
assert_eq!(
|
|
paths.next(),
|
|
Some(format_directory_display(&global_agents_path, /*max_width*/ None).as_str())
|
|
);
|
|
let project_path = paths.next().expect("project agents path");
|
|
assert!(project_path.ends_with("project.md"));
|
|
assert_eq!(paths.next(), None);
|
|
}
|
|
}
|