Files
codex/codex-rs/tui/src/session_log.rs
Eric Traut 25d2dfcad0 Allow naming sessions with /new and /clear (#34605)
## What changed

- Accept an optional session name after `/new` or `/clear`.
- Set the requested name through the app server when starting the new thread and update the attached session with that name.
- Report naming failures in the chat while still attaching the newly created thread.

## Testing

- Cover named `/new` and `/clear` dispatch, app-server name persistence, and the existing unnamed `/clear` behavior.

GitOrigin-RevId: 570886abb4af522123f0693ce05d2415e1c0d902
2026-07-21 20:13:08 +00:00

244 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs::File;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::OnceLock;
use crate::app_command::AppCommand;
use crate::legacy_core::config::Config;
use serde::Serialize;
use serde_json::json;
use crate::app_event::AppEvent;
static LOGGER: LazyLock<SessionLogger> = LazyLock::new(SessionLogger::new);
struct SessionLogger {
file: OnceLock<Mutex<File>>,
}
impl SessionLogger {
fn new() -> Self {
Self {
file: OnceLock::new(),
}
}
fn open(&self, path: PathBuf) -> std::io::Result<()> {
let mut opts = OpenOptions::new();
opts.create(true).truncate(true).write(true);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = opts.open(path)?;
self.file.get_or_init(|| Mutex::new(file));
Ok(())
}
fn write_json_line(&self, value: serde_json::Value) {
let Some(mutex) = self.file.get() else {
return;
};
let mut guard = match mutex.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
match serde_json::to_string(&value) {
Ok(serialized) => {
if let Err(e) = guard.write_all(serialized.as_bytes()) {
tracing::warn!("session log write error: {}", e);
return;
}
if let Err(e) = guard.write_all(b"\n") {
tracing::warn!("session log write error: {}", e);
return;
}
if let Err(e) = guard.flush() {
tracing::warn!("session log flush error: {}", e);
}
}
Err(e) => tracing::warn!("session log serialize error: {}", e),
}
}
fn is_enabled(&self) -> bool {
self.file.get().is_some()
}
}
fn now_ts() -> String {
// RFC3339 for readability; consumers can parse as needed.
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
pub(crate) fn maybe_init(config: &Config) {
let enabled = std::env::var("CODEX_TUI_RECORD_SESSION")
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false);
if !enabled {
return;
}
let path = if let Ok(path) = std::env::var("CODEX_TUI_SESSION_LOG_PATH") {
PathBuf::from(path)
} else {
let mut p = config.log_dir.clone();
let filename = format!(
"session-{}.jsonl",
chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
);
p.push(filename);
p
};
if let Err(e) = LOGGER.open(path.clone()) {
tracing::error!("failed to open session log {:?}: {}", path, e);
return;
}
// Write a header record so we can attach context.
let header = json!({
"ts": now_ts(),
"dir": "meta",
"kind": "session_start",
"cwd": config.cwd,
"model": config.model,
"model_provider_id": config.model_provider_id,
"model_provider_name": config.model_provider.name,
});
LOGGER.write_json_line(header);
}
pub(crate) fn log_inbound_app_event(event: &AppEvent) {
// Log only if enabled
if !LOGGER.is_enabled() {
return;
}
match event {
AppEvent::NewSession { .. } => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "new_session",
});
LOGGER.write_json_line(value);
}
AppEvent::ClearUi { .. } => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "clear_ui",
});
LOGGER.write_json_line(value);
}
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history_cell",
"lines": cell.transcript_lines(u16::MAX).len(),
});
LOGGER.write_json_line(value);
}
AppEvent::StartFileSearch(query) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "file_search_start",
"query": query,
});
LOGGER.write_json_line(value);
}
AppEvent::FileSearchResult { query, matches } => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "file_search_result",
"query": query,
"matches": matches.len(),
});
LOGGER.write_json_line(value);
}
AppEvent::PetPreviewLoaded { request_id, result } => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "app_event",
"variant": "PetPreviewLoaded",
"request_id": request_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
}
AppEvent::PetSelectionLoaded {
request_id,
pet_id,
result,
} => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "app_event",
"variant": "PetSelectionLoaded",
"request_id": request_id,
"pet_id": pet_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
}
// Noise or control flow record variant only
other => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "app_event",
"variant": format!("{other:?}").split('(').next().unwrap_or("app_event"),
});
LOGGER.write_json_line(value);
}
}
}
pub(crate) fn log_outbound_op(op: &AppCommand) {
if !LOGGER.is_enabled() {
return;
}
write_record("from_tui", "op", op);
}
pub(crate) fn log_session_end() {
if !LOGGER.is_enabled() {
return;
}
let value = json!({
"ts": now_ts(),
"dir": "meta",
"kind": "session_end",
});
LOGGER.write_json_line(value);
}
fn write_record<T>(dir: &str, kind: &str, obj: &T)
where
T: Serialize,
{
let value = json!({
"ts": now_ts(),
"dir": dir,
"kind": kind,
"payload": obj,
});
LOGGER.write_json_line(value);
}