mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge 60f6699044 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -839,6 +839,9 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
|
||||
&& matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N'))
|
||||
|| matches!(key.code, KeyCode::Char('\u{000E}'));
|
||||
if is_ctrl_n {
|
||||
if app.new_task.is_none() {
|
||||
continue;
|
||||
}
|
||||
if app.best_of_modal.is_some() {
|
||||
app.best_of_modal = None;
|
||||
needs_redraw = true;
|
||||
|
||||
@@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
help.push(": Apply ".dim());
|
||||
}
|
||||
help.push("o : Set Env ".dim());
|
||||
help.push("Ctrl+N".dim());
|
||||
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
|
||||
if app.new_task.is_some() {
|
||||
help.push("Ctrl+N".dim());
|
||||
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
|
||||
help.push("(editing new task) ".dim());
|
||||
} else {
|
||||
help.push("n : New Task ".dim());
|
||||
@@ -1004,32 +1004,40 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
let inner = overlay_outer(area);
|
||||
const MAX_WIDTH: u16 = 40;
|
||||
const MIN_WIDTH: u16 = 20;
|
||||
const MAX_HEIGHT: u16 = 12;
|
||||
const MIN_HEIGHT: u16 = 6;
|
||||
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
|
||||
let modal_height = inner
|
||||
.height
|
||||
.min(MAX_HEIGHT)
|
||||
.max(inner.height.min(MIN_HEIGHT));
|
||||
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
|
||||
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
|
||||
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
|
||||
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
|
||||
let block = overlay_block().title(title);
|
||||
|
||||
frame.render_widget(Clear, inner);
|
||||
frame.render_widget(block.clone(), inner);
|
||||
let content = overlay_content(inner);
|
||||
frame.render_widget(Clear, modal_area);
|
||||
frame.render_widget(block.clone(), modal_area);
|
||||
let content = overlay_content(modal_area);
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(content);
|
||||
|
||||
let hint = Paragraph::new(Line::from(
|
||||
"Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel"
|
||||
.cyan()
|
||||
.dim(),
|
||||
))
|
||||
.wrap(Wrap { trim: true });
|
||||
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
|
||||
.wrap(Wrap { trim: true });
|
||||
frame.render_widget(hint, rows[0]);
|
||||
|
||||
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
|
||||
let options = [1usize, 2, 3, 4];
|
||||
let mut items: Vec<ListItem> = Vec::new();
|
||||
for &attempts in &options {
|
||||
let mut spans: Vec<ratatui::text::Span> =
|
||||
vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()];
|
||||
let noun = if attempts == 1 { "attempt" } else { "attempts" };
|
||||
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
|
||||
spans.push(" ".into());
|
||||
spans.push(format!("{attempts}x parallel").dim());
|
||||
if attempts == app.best_of_n {
|
||||
|
||||
@@ -1,25 +1,431 @@
|
||||
// This is a WIP. This will eventually contain a real list of common safe Windows commands.
|
||||
pub fn is_safe_command_windows(_command: &[String]) -> bool {
|
||||
use shlex::split as shlex_split;
|
||||
|
||||
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
|
||||
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
|
||||
pub fn is_safe_command_windows(command: &[String]) -> bool {
|
||||
if let Some(commands) = try_parse_powershell_command_sequence(command) {
|
||||
return commands
|
||||
.iter()
|
||||
.all(|cmd| is_safe_powershell_command(cmd.as_slice()));
|
||||
}
|
||||
// Only PowerShell invocations are allowed on Windows for now; anything else is unsafe.
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns each command sequence if the invocation starts with a PowerShell binary.
|
||||
/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences.
|
||||
fn try_parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
let (exe, rest) = command.split_first()?;
|
||||
if !is_powershell_executable(exe) {
|
||||
return None;
|
||||
}
|
||||
parse_powershell_invocation(rest)
|
||||
}
|
||||
|
||||
/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns.
|
||||
fn parse_powershell_invocation(args: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
if args.is_empty() {
|
||||
// Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments.
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut idx = 0;
|
||||
while idx < args.len() {
|
||||
let arg = &args[idx];
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"-command" | "/command" | "-c" => {
|
||||
let script = args.get(idx + 1)?;
|
||||
if idx + 2 != args.len() {
|
||||
// Reject if there is more than one token representing the actual command.
|
||||
// Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra".
|
||||
return None;
|
||||
}
|
||||
return parse_powershell_script(script);
|
||||
}
|
||||
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
|
||||
if idx + 1 != args.len() {
|
||||
// Reject if there are more tokens after the command itself.
|
||||
// Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args.
|
||||
return None;
|
||||
}
|
||||
let script = arg.split_once(':')?.1;
|
||||
return parse_powershell_script(script);
|
||||
}
|
||||
|
||||
// Benign, no-arg flags we tolerate.
|
||||
"-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Explicitly forbidden/opaque or unnecessary for read-only operations.
|
||||
"-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy"
|
||||
| "-workingdirectory" => {
|
||||
// Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1".
|
||||
return None;
|
||||
}
|
||||
|
||||
// Unknown switch → bail conservatively.
|
||||
_ if lower.starts_with('-') => {
|
||||
// Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar".
|
||||
return None;
|
||||
}
|
||||
|
||||
// If we hit non-flag tokens, treat the remainder as a command sequence.
|
||||
// This happens if powershell is invoked without -Command, e.g.
|
||||
// ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"]
|
||||
_ => {
|
||||
return split_into_commands(args[idx..].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script.
|
||||
None
|
||||
}
|
||||
|
||||
/// Tokenizes an inline PowerShell script and delegates to the command splitter.
|
||||
/// Examples of when this is called: pwsh.exe -Command '<script>' or pwsh.exe -Command:<script>
|
||||
fn parse_powershell_script(script: &str) -> Option<Vec<Vec<String>>> {
|
||||
let tokens = shlex_split(script)?;
|
||||
split_into_commands(tokens)
|
||||
}
|
||||
|
||||
/// Splits tokens into pipeline segments while ensuring no unsafe separators slip through.
|
||||
/// e.g. Get-ChildItem | Measure-Object -> [['Get-ChildItem'], ['Measure-Object']]
|
||||
fn split_into_commands(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
|
||||
if tokens.is_empty() {
|
||||
// Examples rejected here: "pwsh -Command ''" and "powershell -Command \"\"".
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
for token in tokens.into_iter() {
|
||||
match token.as_str() {
|
||||
"|" | "||" | "&&" | ";" => {
|
||||
if current.is_empty() {
|
||||
// Examples rejected here: "pwsh -Command '| Get-ChildItem'" and "pwsh -Command '; dir'".
|
||||
return None;
|
||||
}
|
||||
commands.push(current);
|
||||
current = Vec::new();
|
||||
}
|
||||
// Reject if any token embeds separators, redirection, or call operator characters.
|
||||
_ if token.contains(['|', ';', '>', '<', '&']) || token.contains("$(") => {
|
||||
// Examples rejected here: "pwsh -Command 'dir|select'" and "pwsh -Command 'echo hi > out.txt'".
|
||||
return None;
|
||||
}
|
||||
_ => current.push(token),
|
||||
}
|
||||
}
|
||||
|
||||
if current.is_empty() {
|
||||
// Examples rejected here: "pwsh -Command 'dir |'" and "pwsh -Command 'Get-ChildItem ;'".
|
||||
return None;
|
||||
}
|
||||
commands.push(current);
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// Returns true when the executable name is one of the supported PowerShell binaries.
|
||||
fn is_powershell_executable(exe: &str) -> bool {
|
||||
matches!(
|
||||
exe.to_ascii_lowercase().as_str(),
|
||||
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates that a parsed PowerShell command stays within our read-only safelist.
|
||||
/// Everything before this is parsing, and rejecting things that make us feel uncomfortable.
|
||||
fn is_safe_powershell_command(words: &[String]) -> bool {
|
||||
if words.is_empty() {
|
||||
// Examples rejected here: "pwsh -Command ''" and "pwsh -Command \"\"".
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject nested unsafe cmdlets inside parentheses or arguments
|
||||
for w in words.iter() {
|
||||
let inner = w
|
||||
.trim_matches(|c| c == '(' || c == ')')
|
||||
.trim_start_matches('-')
|
||||
.to_ascii_lowercase();
|
||||
if matches!(
|
||||
inner.as_str(),
|
||||
"set-content"
|
||||
| "add-content"
|
||||
| "out-file"
|
||||
| "new-item"
|
||||
| "remove-item"
|
||||
| "move-item"
|
||||
| "copy-item"
|
||||
| "rename-item"
|
||||
| "start-process"
|
||||
| "stop-process"
|
||||
) {
|
||||
// Examples rejected here: "Write-Output (Set-Content foo6.txt 'abc')" and "Get-Content (New-Item bar.txt)".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Block PowerShell call operator or any redirection explicitly.
|
||||
if words.iter().any(|w| {
|
||||
matches!(
|
||||
w.as_str(),
|
||||
"&" | ">" | ">>" | "1>" | "2>" | "2>&1" | "*>" | "<" | "<<"
|
||||
)
|
||||
}) {
|
||||
// Examples rejected here: "pwsh -Command '& Remove-Item foo'" and "pwsh -Command 'Get-Content foo > bar'".
|
||||
return false;
|
||||
}
|
||||
|
||||
let command = words[0]
|
||||
.trim_matches(|c| c == '(' || c == ')')
|
||||
.trim_start_matches('-')
|
||||
.to_ascii_lowercase();
|
||||
match command.as_str() {
|
||||
"echo" | "write-output" | "write-host" => true, // (no redirection allowed)
|
||||
"dir" | "ls" | "get-childitem" | "gci" => true,
|
||||
"cat" | "type" | "gc" | "get-content" => true,
|
||||
"select-string" | "sls" | "findstr" => true,
|
||||
"measure-object" | "measure" => true,
|
||||
"get-location" | "gl" | "pwd" => true,
|
||||
"test-path" | "tp" => true,
|
||||
"resolve-path" | "rvpa" => true,
|
||||
"select-object" | "select" => true,
|
||||
"get-item" => true,
|
||||
|
||||
"git" => is_safe_git_command(words),
|
||||
|
||||
"rg" => is_safe_ripgrep(words),
|
||||
|
||||
// Extra safety: explicitly prohibit common side-effecting cmdlets regardless of args.
|
||||
"set-content" | "add-content" | "out-file" | "new-item" | "remove-item" | "move-item"
|
||||
| "copy-item" | "rename-item" | "start-process" | "stop-process" => {
|
||||
// Examples rejected here: "pwsh -Command 'Set-Content notes.txt data'" and "pwsh -Command 'Remove-Item temp.log'".
|
||||
false
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Examples rejected here: "pwsh -Command 'Invoke-WebRequest https://example.com'" and "pwsh -Command 'Start-Service Spooler'".
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that an `rg` invocation avoids options that can spawn arbitrary executables.
|
||||
fn is_safe_ripgrep(words: &[String]) -> bool {
|
||||
const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &["--pre", "--hostname-bin"];
|
||||
const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &["--search-zip", "-z"];
|
||||
|
||||
!words.iter().skip(1).any(|arg| {
|
||||
let arg_lc = arg.to_ascii_lowercase();
|
||||
// Examples rejected here: "pwsh -Command 'rg --pre cat pattern'" and "pwsh -Command 'rg --search-zip pattern'".
|
||||
UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg_lc.as_str())
|
||||
|| UNSAFE_RIPGREP_OPTIONS_WITH_ARGS
|
||||
.iter()
|
||||
.any(|opt| arg_lc == *opt || arg_lc.starts_with(&format!("{opt}=")))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures a Git command sticks to whitelisted read-only subcommands and flags.
|
||||
fn is_safe_git_command(words: &[String]) -> bool {
|
||||
const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"];
|
||||
|
||||
let mut iter = words.iter().skip(1);
|
||||
while let Some(arg) = iter.next() {
|
||||
let arg_lc = arg.to_ascii_lowercase();
|
||||
|
||||
if arg.starts_with('-') {
|
||||
if arg.eq_ignore_ascii_case("-c") || arg.eq_ignore_ascii_case("--config") {
|
||||
if iter.next().is_none() {
|
||||
// Examples rejected here: "pwsh -Command 'git -c'" and "pwsh -Command 'git --config'".
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if arg_lc.starts_with("-c=")
|
||||
|| arg_lc.starts_with("--config=")
|
||||
|| arg_lc.starts_with("--git-dir=")
|
||||
|| arg_lc.starts_with("--work-tree=")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if arg.eq_ignore_ascii_case("--git-dir") || arg.eq_ignore_ascii_case("--work-tree") {
|
||||
if iter.next().is_none() {
|
||||
// Examples rejected here: "pwsh -Command 'git --git-dir'" and "pwsh -Command 'git --work-tree'".
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str());
|
||||
}
|
||||
|
||||
// Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'".
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_command_windows;
|
||||
use std::string::ToString;
|
||||
|
||||
/// Converts a slice of string literals into owned `String`s for the tests.
|
||||
fn vec_str(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_is_unsafe() {
|
||||
for cmd in [
|
||||
vec_str(&["powershell.exe", "-NoLogo", "-Command", "echo hello"]),
|
||||
vec_str(&["copy", "foo", "bar"]),
|
||||
vec_str(&["del", "file.txt"]),
|
||||
vec_str(&["powershell.exe", "Get-ChildItem"]),
|
||||
] {
|
||||
assert!(!is_safe_command_windows(&cmd));
|
||||
}
|
||||
fn recognizes_safe_powershell_wrappers() {
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-Command",
|
||||
"Get-ChildItem -Path .",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"git status",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"Get-Content",
|
||||
"Cargo.toml",
|
||||
])));
|
||||
|
||||
// pwsh parity
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-ChildItem",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_read_only_pipelines_and_git_usage() {
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"rg --files-with-matches foo | Measure-Object | Select-Object -ExpandProperty Count",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Get-Content foo.rs | Select-Object -Skip 200",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"git -c core.pager=cat show HEAD:foo.rs",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-Command",
|
||||
"-git cat-file -p HEAD:foo.rs",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-Command",
|
||||
"(Get-Content foo.rs -Raw)",
|
||||
])));
|
||||
|
||||
assert!(is_safe_command_windows(&vec_str(&[
|
||||
"pwsh",
|
||||
"-Command",
|
||||
"Get-Item foo.rs | Select-Object Length",
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_powershell_commands_with_side_effects() {
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-Command",
|
||||
"Remove-Item foo.txt",
|
||||
])));
|
||||
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"rg --pre cat",
|
||||
])));
|
||||
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Set-Content foo.txt 'hello'",
|
||||
])));
|
||||
|
||||
// Redirections are blocked
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"echo hi > out.txt",
|
||||
])));
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Get-Content x | Out-File y",
|
||||
])));
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Write-Output foo 2> err.txt",
|
||||
])));
|
||||
|
||||
// Call operator is blocked
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"& Remove-Item foo",
|
||||
])));
|
||||
|
||||
// Chained safe + unsafe must fail
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Get-ChildItem; Remove-Item foo",
|
||||
])));
|
||||
// Nested unsafe cmdlet inside safe command must fail
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Write-Output (Set-Content foo6.txt 'abc')",
|
||||
])));
|
||||
// Additional nested unsafe cmdlet examples must fail
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Write-Host (Remove-Item foo.txt)",
|
||||
])));
|
||||
assert!(!is_safe_command_windows(&vec_str(&[
|
||||
"powershell.exe",
|
||||
"-Command",
|
||||
"Get-Content (New-Item bar.txt)",
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ pub struct Config {
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
pub project_doc_max_bytes: usize,
|
||||
|
||||
/// Additional filenames to try when looking for project-level docs.
|
||||
pub project_doc_fallback_filenames: Vec<String>,
|
||||
|
||||
/// Directory containing all Codex state (defaults to `~/.codex` but can be
|
||||
/// overridden by the `CODEX_HOME` environment variable).
|
||||
pub codex_home: PathBuf,
|
||||
@@ -670,6 +673,9 @@ pub struct ConfigToml {
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
pub project_doc_max_bytes: Option<usize>,
|
||||
|
||||
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
|
||||
pub project_doc_fallback_filenames: Option<Vec<String>>,
|
||||
|
||||
/// Profile to use from the `profiles` map.
|
||||
pub profile: Option<String>,
|
||||
|
||||
@@ -1038,6 +1044,19 @@ impl Config {
|
||||
mcp_servers: cfg.mcp_servers,
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
project_doc_fallback_filenames: cfg
|
||||
.project_doc_fallback_filenames
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
codex_home,
|
||||
history,
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
@@ -1811,6 +1830,7 @@ model_verbosity = "high"
|
||||
mcp_servers: HashMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
@@ -1871,6 +1891,7 @@ model_verbosity = "high"
|
||||
mcp_servers: HashMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
@@ -1946,6 +1967,7 @@ model_verbosity = "high"
|
||||
mcp_servers: HashMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
@@ -2007,6 +2029,7 @@ model_verbosity = "high"
|
||||
mcp_servers: HashMap::new(),
|
||||
model_providers: fixture.model_provider_map.clone(),
|
||||
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
|
||||
project_doc_fallback_filenames: Vec::new(),
|
||||
codex_home: fixture.codex_home(),
|
||||
history: History::default(),
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Project-level documentation discovery.
|
||||
//!
|
||||
//! Project-level documentation can be stored in files named `AGENTS.md`.
|
||||
//! Project-level documentation is primarily stored in files named `AGENTS.md`.
|
||||
//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`.
|
||||
//! We include the concatenation of all files found along the path from the
|
||||
//! repository root to the current working directory as follows:
|
||||
//!
|
||||
@@ -17,8 +18,8 @@ use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::error;
|
||||
|
||||
/// Currently, we only match the filename `AGENTS.md` exactly.
|
||||
const CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"];
|
||||
/// Default filename scanned for project-level docs.
|
||||
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
|
||||
|
||||
/// When both `Config::instructions` and the project doc are present, they will
|
||||
/// be concatenated with the following separator.
|
||||
@@ -152,8 +153,9 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
|
||||
};
|
||||
|
||||
let mut found: Vec<PathBuf> = Vec::new();
|
||||
let candidate_filenames = candidate_filenames(config);
|
||||
for d in search_dirs {
|
||||
for name in CANDIDATE_FILENAMES {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
match std::fs::symlink_metadata(&candidate) {
|
||||
Ok(md) => {
|
||||
@@ -173,6 +175,22 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
|
||||
let mut names: Vec<&'a str> =
|
||||
Vec::with_capacity(1 + config.project_doc_fallback_filenames.len());
|
||||
names.push(DEFAULT_PROJECT_DOC_FILENAME);
|
||||
for candidate in &config.project_doc_fallback_filenames {
|
||||
let candidate = candidate.as_str();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !names.contains(&candidate) {
|
||||
names.push(candidate);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -202,6 +220,20 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
fn make_config_with_fallback(
|
||||
root: &TempDir,
|
||||
limit: usize,
|
||||
instructions: Option<&str>,
|
||||
fallbacks: &[&str],
|
||||
) -> Config {
|
||||
let mut config = make_config(root, limit, instructions);
|
||||
config.project_doc_fallback_filenames = fallbacks
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
config
|
||||
}
|
||||
|
||||
/// AGENTS.md missing – should yield `None`.
|
||||
#[tokio::test]
|
||||
async fn no_doc_file_returns_none() {
|
||||
@@ -347,4 +379,45 @@ mod tests {
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
}
|
||||
|
||||
/// When AGENTS.md is absent but a configured fallback exists, the fallback is used.
|
||||
#[tokio::test]
|
||||
async fn uses_configured_fallback_when_agents_missing() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap();
|
||||
|
||||
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]);
|
||||
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("fallback doc expected");
|
||||
|
||||
assert_eq!(res, "example instructions");
|
||||
}
|
||||
|
||||
/// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present.
|
||||
#[tokio::test]
|
||||
async fn agents_md_preferred_over_fallbacks() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap();
|
||||
fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap();
|
||||
|
||||
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]);
|
||||
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("AGENTS.md should win");
|
||||
|
||||
assert_eq!(res, "primary");
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
|
||||
assert_eq!(discovery.len(), 1);
|
||||
assert!(
|
||||
discovery[0]
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.eq(DEFAULT_PROJECT_DOC_FILENAME)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +40,24 @@ pub struct ConversationItem {
|
||||
pub head: Vec<serde_json::Value>,
|
||||
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
|
||||
pub tail: Vec<serde_json::Value>,
|
||||
/// RFC3339 timestamp string for when the session was created, if available.
|
||||
pub created_at: Option<String>,
|
||||
/// RFC3339 timestamp string for the most recent response in the tail, if available.
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadTailSummary {
|
||||
head: Vec<serde_json::Value>,
|
||||
tail: Vec<serde_json::Value>,
|
||||
saw_session_meta: bool,
|
||||
saw_user_event: bool,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Hard cap to bound worst‑case work per request.
|
||||
const MAX_SCAN_FILES: usize = 100;
|
||||
const MAX_SCAN_FILES: usize = 10000;
|
||||
const HEAD_RECORD_LIMIT: usize = 10;
|
||||
const TAIL_RECORD_LIMIT: usize = 10;
|
||||
|
||||
@@ -179,13 +193,26 @@ async fn traverse_directories_for_paths(
|
||||
}
|
||||
// Read head and simultaneously detect message events within the same
|
||||
// first N JSONL records to avoid a second file read.
|
||||
let (head, tail, saw_session_meta, saw_user_event) =
|
||||
read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
|
||||
.await
|
||||
.unwrap_or((Vec::new(), Vec::new(), false, false));
|
||||
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Apply filters: must have session meta and at least one user message event
|
||||
if saw_session_meta && saw_user_event {
|
||||
items.push(ConversationItem { path, head, tail });
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
let HeadTailSummary {
|
||||
head,
|
||||
tail,
|
||||
created_at,
|
||||
mut updated_at,
|
||||
..
|
||||
} = summary;
|
||||
updated_at = updated_at.or_else(|| created_at.clone());
|
||||
items.push(ConversationItem {
|
||||
path,
|
||||
head,
|
||||
tail,
|
||||
created_at,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,17 +320,15 @@ async fn read_head_and_tail(
|
||||
path: &Path,
|
||||
head_limit: usize,
|
||||
tail_limit: usize,
|
||||
) -> io::Result<(Vec<serde_json::Value>, Vec<serde_json::Value>, bool, bool)> {
|
||||
) -> io::Result<HeadTailSummary> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut head: Vec<serde_json::Value> = Vec::new();
|
||||
let mut saw_session_meta = false;
|
||||
let mut saw_user_event = false;
|
||||
let mut summary = HeadTailSummary::default();
|
||||
|
||||
while head.len() < head_limit {
|
||||
while summary.head.len() < head_limit {
|
||||
let line_opt = lines.next_line().await?;
|
||||
let Some(line) = line_opt else { break };
|
||||
let trimmed = line.trim();
|
||||
@@ -316,14 +341,22 @@ async fn read_head_and_tail(
|
||||
|
||||
match rollout_line.item {
|
||||
RolloutItem::SessionMeta(session_meta_line) => {
|
||||
summary.created_at = summary
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
if let Ok(val) = serde_json::to_value(session_meta_line) {
|
||||
head.push(val);
|
||||
saw_session_meta = true;
|
||||
summary.head.push(val);
|
||||
summary.saw_session_meta = true;
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(item) => {
|
||||
summary.created_at = summary
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
if let Ok(val) = serde_json::to_value(item) {
|
||||
head.push(val);
|
||||
summary.head.push(val);
|
||||
}
|
||||
}
|
||||
RolloutItem::TurnContext(_) => {
|
||||
@@ -334,28 +367,30 @@ async fn read_head_and_tail(
|
||||
}
|
||||
RolloutItem::EventMsg(ev) => {
|
||||
if matches!(ev, EventMsg::UserMessage(_)) {
|
||||
saw_user_event = true;
|
||||
summary.saw_user_event = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tail = if tail_limit == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
read_tail_records(path, tail_limit).await?
|
||||
};
|
||||
|
||||
Ok((head, tail, saw_session_meta, saw_user_event))
|
||||
if tail_limit != 0 {
|
||||
let (tail, updated_at) = read_tail_records(path, tail_limit).await?;
|
||||
summary.tail = tail;
|
||||
summary.updated_at = updated_at;
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn read_tail_records(path: &Path, max_records: usize) -> io::Result<Vec<serde_json::Value>> {
|
||||
async fn read_tail_records(
|
||||
path: &Path,
|
||||
max_records: usize,
|
||||
) -> io::Result<(Vec<serde_json::Value>, Option<String>)> {
|
||||
use std::io::SeekFrom;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
|
||||
if max_records == 0 {
|
||||
return Ok(Vec::new());
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: usize = 8192;
|
||||
@@ -363,24 +398,28 @@ async fn read_tail_records(path: &Path, max_records: usize) -> io::Result<Vec<se
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
let mut pos = file.seek(SeekFrom::End(0)).await?;
|
||||
if pos == 0 {
|
||||
return Ok(Vec::new());
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut latest_timestamp: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let slice_start = match (pos > 0, buffer.iter().position(|&b| b == b'\n')) {
|
||||
(true, Some(idx)) => idx + 1,
|
||||
_ => 0,
|
||||
};
|
||||
let tail = collect_last_response_values(&buffer[slice_start..], max_records);
|
||||
let (tail, newest_ts) = collect_last_response_values(&buffer[slice_start..], max_records);
|
||||
if latest_timestamp.is_none() {
|
||||
latest_timestamp = newest_ts.clone();
|
||||
}
|
||||
if tail.len() >= max_records || pos == 0 {
|
||||
return Ok(tail);
|
||||
return Ok((tail, latest_timestamp.or(newest_ts)));
|
||||
}
|
||||
|
||||
let read_size = CHUNK_SIZE.min(pos as usize);
|
||||
if read_size == 0 {
|
||||
return Ok(tail);
|
||||
return Ok((tail, latest_timestamp.or(newest_ts)));
|
||||
}
|
||||
pos -= read_size as u64;
|
||||
file.seek(SeekFrom::Start(pos)).await?;
|
||||
@@ -391,15 +430,19 @@ async fn read_tail_records(path: &Path, max_records: usize) -> io::Result<Vec<se
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_last_response_values(buffer: &[u8], max_records: usize) -> Vec<serde_json::Value> {
|
||||
fn collect_last_response_values(
|
||||
buffer: &[u8],
|
||||
max_records: usize,
|
||||
) -> (Vec<serde_json::Value>, Option<String>) {
|
||||
use std::borrow::Cow;
|
||||
|
||||
if buffer.is_empty() || max_records == 0 {
|
||||
return Vec::new();
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
|
||||
let text: Cow<'_, str> = String::from_utf8_lossy(buffer);
|
||||
let mut collected_rev: Vec<serde_json::Value> = Vec::new();
|
||||
let mut latest_timestamp: Option<String> = None;
|
||||
for line in text.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -407,9 +450,13 @@ fn collect_last_response_values(buffer: &[u8], max_records: usize) -> Vec<serde_
|
||||
}
|
||||
let parsed: serde_json::Result<RolloutLine> = serde_json::from_str(trimmed);
|
||||
let Ok(rollout_line) = parsed else { continue };
|
||||
if let RolloutItem::ResponseItem(item) = rollout_line.item
|
||||
&& let Ok(val) = serde_json::to_value(item)
|
||||
let RolloutLine { timestamp, item } = rollout_line;
|
||||
if let RolloutItem::ResponseItem(item) = item
|
||||
&& let Ok(val) = serde_json::to_value(&item)
|
||||
{
|
||||
if latest_timestamp.is_none() {
|
||||
latest_timestamp = Some(timestamp.clone());
|
||||
}
|
||||
collected_rev.push(val);
|
||||
if collected_rev.len() == max_records {
|
||||
break;
|
||||
@@ -417,7 +464,7 @@ fn collect_last_response_values(buffer: &[u8], max_records: usize) -> Vec<serde_
|
||||
}
|
||||
}
|
||||
collected_rev.reverse();
|
||||
collected_rev
|
||||
(collected_rev, latest_timestamp)
|
||||
}
|
||||
|
||||
/// Locate a recorded conversation rollout file by its UUID string using the existing
|
||||
|
||||
@@ -159,16 +159,22 @@ async fn test_list_conversations_latest_first() {
|
||||
path: p1,
|
||||
head: head_3,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-03T12-00-00".into()),
|
||||
updated_at: Some("2025-01-03T12-00-00".into()),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-02T12-00-00".into()),
|
||||
updated_at: Some("2025-01-02T12-00-00".into()),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p3,
|
||||
head: head_1,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-01T12-00-00".into()),
|
||||
updated_at: Some("2025-01-01T12-00-00".into()),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor),
|
||||
@@ -235,11 +241,15 @@ async fn test_pagination_cursor() {
|
||||
path: p5,
|
||||
head: head_5,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-05T09-00-00".into()),
|
||||
updated_at: Some("2025-03-05T09-00-00".into()),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p4,
|
||||
head: head_4,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-04T09-00-00".into()),
|
||||
updated_at: Some("2025-03-04T09-00-00".into()),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor1.clone()),
|
||||
@@ -287,11 +297,15 @@ async fn test_pagination_cursor() {
|
||||
path: p3,
|
||||
head: head_3,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-03T09-00-00".into()),
|
||||
updated_at: Some("2025-03-03T09-00-00".into()),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-02T09-00-00".into()),
|
||||
updated_at: Some("2025-03-02T09-00-00".into()),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor2.clone()),
|
||||
@@ -324,6 +338,8 @@ async fn test_pagination_cursor() {
|
||||
path: p1,
|
||||
head: head_1,
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-03-01T09-00-00".into()),
|
||||
updated_at: Some("2025-03-01T09-00-00".into()),
|
||||
}],
|
||||
next_cursor: Some(expected_cursor3),
|
||||
num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01
|
||||
@@ -367,6 +383,8 @@ async fn test_get_conversation_contents() {
|
||||
path: expected_path,
|
||||
head: expected_head,
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.into()),
|
||||
updated_at: Some(ts.into()),
|
||||
}],
|
||||
next_cursor: Some(expected_cursor),
|
||||
num_scanned_files: 1,
|
||||
@@ -449,18 +467,23 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
|
||||
|
||||
let expected: Vec<serde_json::Value> = (total_messages - tail_len..total_messages)
|
||||
.map(|idx| {
|
||||
serde_json::to_value(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: format!("reply-{idx}"),
|
||||
}],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("reply-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
.expect("serialize response item")
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(item.tail, expected);
|
||||
assert_eq!(item.created_at.as_deref(), Some(ts));
|
||||
let expected_updated = format!("{ts}-{last:02}", last = total_messages - 1);
|
||||
assert_eq!(item.updated_at.as_deref(), Some(expected_updated.as_str()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -526,18 +549,25 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
|
||||
|
||||
let expected: Vec<serde_json::Value> = (0..3)
|
||||
.map(|idx| {
|
||||
serde_json::to_value(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: format!("short-{idx}"),
|
||||
}],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("short-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
.expect("serialize response item")
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(tail, &expected);
|
||||
let expected_updated = format!("{ts}-{last:02}", last = 2);
|
||||
assert_eq!(
|
||||
page.items[0].updated_at.as_deref(),
|
||||
Some(expected_updated.as_str())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -615,18 +645,25 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
|
||||
|
||||
let expected: Vec<serde_json::Value> = (0..4)
|
||||
.map(|idx| {
|
||||
serde_json::to_value(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".into(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: format!("response-{idx}"),
|
||||
}],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": format!("response-{idx}"),
|
||||
}
|
||||
],
|
||||
})
|
||||
.expect("serialize response item")
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(tail, &expected);
|
||||
let expected_updated = format!("{ts}-{last:02}", last = 3);
|
||||
assert_eq!(
|
||||
page.items[0].updated_at.as_deref(),
|
||||
Some(expected_updated.as_str())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -676,11 +713,15 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
path: p3,
|
||||
head: head(u3),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
},
|
||||
ConversationItem {
|
||||
path: p2,
|
||||
head: head(u2),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
},
|
||||
],
|
||||
next_cursor: Some(expected_cursor1.clone()),
|
||||
@@ -704,6 +745,8 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
path: p1,
|
||||
head: head(u1),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
}],
|
||||
next_cursor: Some(expected_cursor2),
|
||||
num_scanned_files: 3, // scanned u3, u2 (anchor), u1
|
||||
|
||||
@@ -148,19 +148,20 @@ fn print_colored_warning_device_code() {
|
||||
/// Full device code login flow.
|
||||
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let auth_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
|
||||
let base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
|
||||
print_colored_warning_device_code();
|
||||
println!("⏳ Generating a new 9-digit device code for authentication...\n");
|
||||
let uc = request_user_code(&client, &auth_base_url, &opts.client_id).await?;
|
||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||
|
||||
println!(
|
||||
"To authenticate, visit: {}/deviceauth/authorize and enter code: {}",
|
||||
auth_base_url, uc.user_code
|
||||
api_base_url, uc.user_code
|
||||
);
|
||||
|
||||
let code_resp = poll_for_token(
|
||||
&client,
|
||||
&auth_base_url,
|
||||
&api_base_url,
|
||||
&uc.device_auth_id,
|
||||
&uc.user_code,
|
||||
uc.interval,
|
||||
@@ -172,10 +173,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
code_challenge: code_resp.code_challenge,
|
||||
};
|
||||
println!("authorization code received");
|
||||
let redirect_uri = format!("{}/deviceauth/callback", opts.issuer.trim_end_matches('/'));
|
||||
let redirect_uri = format!("{base_url}/deviceauth/callback");
|
||||
|
||||
let tokens = crate::server::exchange_code_for_tokens(
|
||||
&opts.issuer,
|
||||
base_url,
|
||||
&opts.client_id,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct Args {
|
||||
#[derive(Serialize)]
|
||||
struct ServerInfo {
|
||||
port: u16,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
/// Entry point for the library main, for parity with other crates.
|
||||
@@ -100,15 +101,17 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
let parent_display = parent.display();
|
||||
fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let info = ServerInfo { port };
|
||||
let data = serde_json::to_vec(&info).context("serialize startup info")?;
|
||||
let p = path.display();
|
||||
let mut f = File::create(path).with_context(|| format!("create {p}"))?;
|
||||
f.write_all(&data).with_context(|| format!("write {p}"))?;
|
||||
f.write_all(b"\n").with_context(|| format!("newline {p}"))?;
|
||||
|
||||
let info = ServerInfo {
|
||||
port,
|
||||
pid: std::process::id(),
|
||||
};
|
||||
let mut data = serde_json::to_string(&info)?;
|
||||
data.push('\n');
|
||||
let mut f = File::create(path)?;
|
||||
f.write_all(data.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) struct ChatComposer {
|
||||
custom_prompts: Vec<CustomPrompt>,
|
||||
footer_mode: FooterMode,
|
||||
footer_hint_override: Option<Vec<(String, String)>>,
|
||||
context_window_percent: Option<u8>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -150,6 +151,7 @@ impl ChatComposer {
|
||||
custom_prompts: Vec::new(),
|
||||
footer_mode: FooterMode::ShortcutPrompt,
|
||||
footer_hint_override: None,
|
||||
context_window_percent: None,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -1317,6 +1319,7 @@ impl ChatComposer {
|
||||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
context_window_percent: self.context_window_percent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1450,12 @@ impl ChatComposer {
|
||||
self.is_task_running = running;
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<u8>) {
|
||||
if self.context_window_percent != percent {
|
||||
self.context_window_percent = percent;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||||
self.esc_backtrack_hint = show;
|
||||
if show {
|
||||
|
||||
@@ -5,6 +5,7 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::iter;
|
||||
|
||||
@@ -14,6 +15,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) context_window_percent: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -75,7 +77,13 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::ShortcutPrompt => vec![dim_line(indent_text("? for shortcuts"))],
|
||||
FooterMode::ShortcutPrompt => {
|
||||
if props.is_task_running {
|
||||
vec![context_window_line(props.context_window_percent)]
|
||||
} else {
|
||||
vec![dim_line(indent_text("? for shortcuts"))]
|
||||
}
|
||||
}
|
||||
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
@@ -211,6 +219,21 @@ fn dim_line(text: String) -> Line<'static> {
|
||||
Line::from(text).dim()
|
||||
}
|
||||
|
||||
fn context_window_line(percent: Option<u8>) -> Line<'static> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(indent_text("").into());
|
||||
match percent {
|
||||
Some(percent) => {
|
||||
spans.push(format!("{percent}%").bold());
|
||||
spans.push(" context left".dim());
|
||||
}
|
||||
None => {
|
||||
spans.push("? for shortcuts".dim());
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShortcutId {
|
||||
Commands,
|
||||
@@ -398,6 +421,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -408,6 +432,7 @@ mod tests {
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -418,6 +443,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -428,6 +454,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -438,6 +465,7 @@ mod tests {
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -448,6 +476,18 @@ mod tests {
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_context_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutPrompt,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
context_window_percent: Some(72),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ pub(crate) struct BottomPane {
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
/// Queued user messages to show under the status indicator.
|
||||
queued_user_messages: Vec<String>,
|
||||
context_window_percent: Option<u8>,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -100,6 +101,7 @@ impl BottomPane {
|
||||
status: None,
|
||||
queued_user_messages: Vec::new(),
|
||||
esc_backtrack_hint: false,
|
||||
context_window_percent: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +343,16 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_context_window_percent(&mut self, percent: Option<u8>) {
|
||||
if self.context_window_percent == percent {
|
||||
return;
|
||||
}
|
||||
|
||||
self.context_window_percent = percent;
|
||||
self.composer.set_context_window_percent(percent);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Show a generic list selection view with the provided items.
|
||||
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
|
||||
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 72% context left "
|
||||
@@ -396,8 +396,16 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
if info.is_some() {
|
||||
self.token_info = info;
|
||||
if let Some(info) = info {
|
||||
let context_window = info
|
||||
.model_context_window
|
||||
.or(self.config.model_context_window);
|
||||
let percent = context_window.map(|window| {
|
||||
info.last_token_usage
|
||||
.percent_of_context_window_remaining(window)
|
||||
});
|
||||
self.bottom_pane.set_context_window_percent(percent);
|
||||
self.token_info = Some(info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1555,16 +1563,16 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
let default_usage;
|
||||
let usage_ref = if let Some(ti) = &self.token_info {
|
||||
&ti.total_token_usage
|
||||
let default_usage = TokenUsage::default();
|
||||
let (total_usage, context_usage) = if let Some(ti) = &self.token_info {
|
||||
(&ti.total_token_usage, Some(&ti.last_token_usage))
|
||||
} else {
|
||||
default_usage = TokenUsage::default();
|
||||
&default_usage
|
||||
(&default_usage, Some(&default_usage))
|
||||
};
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
&self.config,
|
||||
usage_ref,
|
||||
total_usage,
|
||||
context_usage,
|
||||
&self.conversation_id,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
));
|
||||
|
||||
+1
@@ -3,3 +3,4 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: exec_blob
|
||||
---
|
||||
• Ran sleep 1
|
||||
└ (no output)
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1445
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
|
||||
@@ -366,26 +366,35 @@ impl ExecCell {
|
||||
include_prefix: false,
|
||||
},
|
||||
);
|
||||
let trimmed_output =
|
||||
Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines);
|
||||
|
||||
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
|
||||
let output_wrap_width = layout.output_block.wrap_width(width);
|
||||
let output_opts =
|
||||
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
|
||||
for line in trimmed_output {
|
||||
push_owned_lines(
|
||||
&word_wrap_line(&line, output_opts.clone()),
|
||||
&mut wrapped_output,
|
||||
);
|
||||
}
|
||||
|
||||
if !wrapped_output.is_empty() {
|
||||
if raw_output_lines.is_empty() {
|
||||
lines.extend(prefix_lines(
|
||||
wrapped_output,
|
||||
vec![Line::from("(no output)".dim())],
|
||||
Span::from(layout.output_block.initial_prefix).dim(),
|
||||
Span::from(layout.output_block.subsequent_prefix),
|
||||
));
|
||||
} else {
|
||||
let trimmed_output =
|
||||
Self::truncate_lines_middle(&raw_output_lines, layout.output_max_lines);
|
||||
|
||||
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
|
||||
let output_wrap_width = layout.output_block.wrap_width(width);
|
||||
let output_opts =
|
||||
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
|
||||
for line in trimmed_output {
|
||||
push_owned_lines(
|
||||
&word_wrap_line(&line, output_opts.clone()),
|
||||
&mut wrapped_output,
|
||||
);
|
||||
}
|
||||
|
||||
if !wrapped_output.is_empty() {
|
||||
lines.extend(prefix_lines(
|
||||
wrapped_output,
|
||||
Span::from(layout.output_block.initial_prefix).dim(),
|
||||
Span::from(layout.output_block.subsequent_prefix),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use ratatui::text::Span;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
@@ -110,7 +111,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if let Ok(size) = alt.tui.terminal.size() {
|
||||
let list_height = size.height.saturating_sub(3) as usize;
|
||||
let list_height = size.height.saturating_sub(4) as usize;
|
||||
state.update_view_rows(list_height);
|
||||
state.ensure_minimum_rows_for_view(list_height);
|
||||
}
|
||||
@@ -218,7 +219,8 @@ impl SearchState {
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
ts: Option<DateTime<Utc>>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
@@ -564,13 +566,16 @@ fn rows_from_items(items: Vec<ConversationItem>) -> Vec<Row> {
|
||||
}
|
||||
|
||||
fn head_to_row(item: &ConversationItem) -> Row {
|
||||
let mut ts: Option<DateTime<Utc>> = None;
|
||||
if let Some(first) = item.head.first()
|
||||
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
|
||||
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
|
||||
{
|
||||
ts = Some(parsed.with_timezone(&Utc));
|
||||
}
|
||||
let created_at = item
|
||||
.created_at
|
||||
.as_deref()
|
||||
.and_then(parse_timestamp_str)
|
||||
.or_else(|| item.head.first().and_then(extract_timestamp));
|
||||
let updated_at = item
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.and_then(parse_timestamp_str)
|
||||
.or(created_at);
|
||||
|
||||
let preview = preview_from_head(&item.head)
|
||||
.map(|s| s.trim().to_string())
|
||||
@@ -580,10 +585,25 @@ fn head_to_row(item: &ConversationItem) -> Row {
|
||||
Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
ts,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn extract_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
||||
value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn preview_from_head(head: &[serde_json::Value]) -> Option<String> {
|
||||
head.iter()
|
||||
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
||||
@@ -627,10 +647,11 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
let height = tui.terminal.size()?.height;
|
||||
tui.draw(height, |frame| {
|
||||
let area = frame.area();
|
||||
let [header, search, list, hint] = Layout::vertical([
|
||||
let [header, search, columns, list, hint] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(area.height.saturating_sub(3)),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(area.height.saturating_sub(4)),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
@@ -649,8 +670,11 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
};
|
||||
frame.render_widget_ref(Line::from(q), search);
|
||||
|
||||
// List
|
||||
render_list(frame, list, state);
|
||||
let metrics = calculate_column_metrics(&state.filtered_rows);
|
||||
|
||||
// Column headers and list
|
||||
render_column_headers(frame, columns, &metrics);
|
||||
render_list(frame, list, state, &metrics);
|
||||
|
||||
// Hint line
|
||||
let hint_line: Line = vec![
|
||||
@@ -671,7 +695,12 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
|
||||
fn render_list(
|
||||
frame: &mut crate::custom_terminal::Frame,
|
||||
area: Rect,
|
||||
state: &PickerState,
|
||||
metrics: &ColumnMetrics,
|
||||
) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -686,20 +715,58 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
|
||||
let capacity = area.height as usize;
|
||||
let start = state.scroll_top.min(rows.len().saturating_sub(1));
|
||||
let end = rows.len().min(start + capacity);
|
||||
let labels = &metrics.labels;
|
||||
let mut y = area.y;
|
||||
|
||||
for (idx, row) in rows[start..end].iter().enumerate() {
|
||||
let max_created_width = metrics.max_created_width;
|
||||
let max_updated_width = metrics.max_updated_width;
|
||||
|
||||
for (idx, (row, (created_label, updated_label))) in rows[start..end]
|
||||
.iter()
|
||||
.zip(labels[start..end].iter())
|
||||
.enumerate()
|
||||
{
|
||||
let is_sel = start + idx == state.selected;
|
||||
let marker = if is_sel { "> ".bold() } else { " ".into() };
|
||||
let ts = row
|
||||
.ts
|
||||
.map(human_time_ago)
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.dim();
|
||||
let max_cols = area.width.saturating_sub(6) as usize;
|
||||
let preview = truncate_text(&row.preview, max_cols);
|
||||
let marker_width = 2usize;
|
||||
let created_span = if max_created_width == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
|
||||
};
|
||||
let updated_span = if max_updated_width == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
|
||||
};
|
||||
let mut preview_width = area.width as usize;
|
||||
preview_width = preview_width.saturating_sub(marker_width);
|
||||
if max_created_width > 0 {
|
||||
preview_width = preview_width.saturating_sub(max_created_width + 2);
|
||||
}
|
||||
if max_updated_width > 0 {
|
||||
preview_width = preview_width.saturating_sub(max_updated_width + 2);
|
||||
}
|
||||
let add_leading_gap = max_created_width == 0 && max_updated_width == 0;
|
||||
if add_leading_gap {
|
||||
preview_width = preview_width.saturating_sub(2);
|
||||
}
|
||||
let preview = truncate_text(&row.preview, preview_width);
|
||||
let mut spans: Vec<Span> = vec![marker];
|
||||
if let Some(created) = created_span {
|
||||
spans.push(created);
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if let Some(updated) = updated_span {
|
||||
spans.push(updated);
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if add_leading_gap {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.push(preview.into());
|
||||
|
||||
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
|
||||
let line: Line = spans.into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(line, rect);
|
||||
y = y.saturating_add(1);
|
||||
@@ -775,14 +842,89 @@ fn human_time_ago(ts: DateTime<Utc>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_created_label(row: &Row) -> String {
|
||||
row.created_at
|
||||
.map(human_time_ago)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn format_updated_label(row: &Row) -> String {
|
||||
match (row.updated_at, row.created_at) {
|
||||
(Some(updated), _) => human_time_ago(updated),
|
||||
(None, Some(created)) => human_time_ago(created),
|
||||
(None, None) => "-".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_column_headers(
|
||||
frame: &mut crate::custom_terminal::Frame,
|
||||
area: Rect,
|
||||
metrics: &ColumnMetrics,
|
||||
) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut spans: Vec<Span> = vec![" ".into()];
|
||||
if metrics.max_created_width > 0 {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "Created",
|
||||
width = metrics.max_created_width
|
||||
);
|
||||
spans.push(Span::from(label).bold());
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if metrics.max_updated_width > 0 {
|
||||
let label = format!(
|
||||
"{text:<width$}",
|
||||
text = "Updated",
|
||||
width = metrics.max_updated_width
|
||||
);
|
||||
spans.push(Span::from(label).bold());
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.push("Conversation".bold());
|
||||
frame.render_widget_ref(Line::from(spans), area);
|
||||
}
|
||||
|
||||
struct ColumnMetrics {
|
||||
max_created_width: usize,
|
||||
max_updated_width: usize,
|
||||
labels: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn calculate_column_metrics(rows: &[Row]) -> ColumnMetrics {
|
||||
let mut labels: Vec<(String, String)> = Vec::with_capacity(rows.len());
|
||||
let mut max_created_width = UnicodeWidthStr::width("Created");
|
||||
let mut max_updated_width = UnicodeWidthStr::width("Updated");
|
||||
|
||||
for row in rows {
|
||||
let created = format_created_label(row);
|
||||
let updated = format_updated_label(row);
|
||||
max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str()));
|
||||
max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str()));
|
||||
labels.push((created, updated));
|
||||
}
|
||||
|
||||
ColumnMetrics {
|
||||
max_created_width,
|
||||
max_updated_width,
|
||||
labels,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -805,6 +947,8 @@ mod tests {
|
||||
path: PathBuf::from(path),
|
||||
head: head_with_ts_and_user_text(ts, &[preview]),
|
||||
tail: Vec::new(),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,11 +1009,15 @@ mod tests {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
};
|
||||
let b = ConversationItem {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
||||
tail: Vec::new(),
|
||||
created_at: Some("2025-01-02T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-02T00:00:00Z".into()),
|
||||
};
|
||||
let rows = rows_from_items(vec![a, b]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
@@ -878,6 +1026,101 @@ mod tests {
|
||||
assert!(rows[1].preview.contains('B'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_uses_tail_timestamp_for_updated_at() {
|
||||
let head = head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["Hello"]);
|
||||
let tail = vec![json!({
|
||||
"timestamp": "2025-01-01T01:00:00Z",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "hi",
|
||||
}
|
||||
],
|
||||
})];
|
||||
let item = ConversationItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head,
|
||||
tail,
|
||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-01T01:00:00Z".into()),
|
||||
};
|
||||
|
||||
let row = head_to_row(&item);
|
||||
let expected_created = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let expected_updated = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
|
||||
assert_eq!(row.created_at, Some(expected_created));
|
||||
assert_eq!(row.updated_at, Some(expected_updated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_table_snapshot() {
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
let mut state =
|
||||
PickerState::new(PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader);
|
||||
|
||||
let now = Utc::now();
|
||||
let rows = vec![
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
preview: String::from("Fix resume picker timestamps"),
|
||||
created_at: Some(now - Duration::minutes(16)),
|
||||
updated_at: Some(now - Duration::seconds(42)),
|
||||
},
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
preview: String::from("Investigate lazy pagination cap"),
|
||||
created_at: Some(now - Duration::hours(1)),
|
||||
updated_at: Some(now - Duration::minutes(35)),
|
||||
},
|
||||
Row {
|
||||
path: PathBuf::from("/tmp/c.jsonl"),
|
||||
preview: String::from("Explain the codebase"),
|
||||
created_at: Some(now - Duration::hours(2)),
|
||||
updated_at: Some(now - Duration::hours(2)),
|
||||
},
|
||||
];
|
||||
state.all_rows = rows.clone();
|
||||
state.filtered_rows = rows;
|
||||
state.view_rows = Some(3);
|
||||
state.selected = 1;
|
||||
state.scroll_top = 0;
|
||||
state.update_view_rows(3);
|
||||
|
||||
let metrics = calculate_column_metrics(&state.filtered_rows);
|
||||
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 6;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
|
||||
{
|
||||
let mut frame = terminal.get_frame();
|
||||
let area = frame.area();
|
||||
let segments =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
|
||||
render_column_headers(&mut frame, segments[0], &metrics);
|
||||
render_list(&mut frame, segments[1], &state, &metrics);
|
||||
}
|
||||
terminal.flush().expect("flush");
|
||||
|
||||
let snapshot = terminal.backend().to_string();
|
||||
assert_snapshot!("resume_picker_table", snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pageless_scrolling_deduplicates_and_keeps_order() {
|
||||
let loader: PageLoader = Arc::new(|_| {});
|
||||
|
||||
+1
@@ -6,3 +6,4 @@ expression: rendered
|
||||
│ ough_to_wrap
|
||||
│ second_token_is_also_lon
|
||||
│ … +1 lines
|
||||
└ (no output)
|
||||
|
||||
+1
@@ -4,3 +4,4 @@ expression: rendered
|
||||
---
|
||||
• Ran echo one
|
||||
│ echo two
|
||||
└ (no output)
|
||||
|
||||
+1
@@ -5,3 +5,4 @@ expression: rendered
|
||||
• Ran set -o pipefail
|
||||
│ cargo test
|
||||
│ --all-features --quiet
|
||||
└ (no output)
|
||||
|
||||
+1
@@ -3,3 +3,4 @@ source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran echo ok
|
||||
└ (no output)
|
||||
|
||||
+1
@@ -5,3 +5,4 @@ expression: rendered
|
||||
• Ran a_very_long_token_
|
||||
│ without_spaces_to_
|
||||
│ force_wrapping
|
||||
└ (no output)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/resume_picker.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Created Updated Conversation
|
||||
16 minutes ago 42 seconds ago Fix resume picker timestamps
|
||||
> 1 hour ago 35 minutes ago Investigate lazy pagination cap
|
||||
2 hours ago 2 hours ago Explain the codebase
|
||||
@@ -29,11 +29,19 @@ use super::rate_limits::compose_rate_limit_data;
|
||||
use super::rate_limits::format_status_limit_summary;
|
||||
use super::rate_limits::render_status_limit_progress_bar;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StatusContextWindowData {
|
||||
percent_remaining: u8,
|
||||
tokens_in_context: u64,
|
||||
window: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusTokenUsageData {
|
||||
total: u64,
|
||||
input: u64,
|
||||
output: u64,
|
||||
context_window: Option<StatusContextWindowData>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -52,12 +60,13 @@ struct StatusHistoryCell {
|
||||
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
total_usage: &TokenUsage,
|
||||
context_usage: Option<&TokenUsage>,
|
||||
session_id: &Option<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
|
||||
let card = StatusHistoryCell::new(config, usage, session_id, rate_limits);
|
||||
let card = StatusHistoryCell::new(config, total_usage, context_usage, session_id, rate_limits);
|
||||
|
||||
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
|
||||
}
|
||||
@@ -65,7 +74,8 @@ pub(crate) fn new_status_output(
|
||||
impl StatusHistoryCell {
|
||||
fn new(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
total_usage: &TokenUsage,
|
||||
context_usage: Option<&TokenUsage>,
|
||||
session_id: &Option<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
) -> Self {
|
||||
@@ -84,10 +94,19 @@ impl StatusHistoryCell {
|
||||
let agents_summary = compose_agents_summary(config);
|
||||
let account = compose_account_display(config);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let context_window = config.model_context_window.and_then(|window| {
|
||||
context_usage.map(|usage| StatusContextWindowData {
|
||||
percent_remaining: usage.percent_of_context_window_remaining(window),
|
||||
tokens_in_context: usage.tokens_in_context_window(),
|
||||
window,
|
||||
})
|
||||
});
|
||||
|
||||
let token_usage = StatusTokenUsageData {
|
||||
total: usage.blended_total(),
|
||||
input: usage.non_cached_input(),
|
||||
output: usage.output_tokens,
|
||||
total: total_usage.blended_total(),
|
||||
input: total_usage.non_cached_input(),
|
||||
output: total_usage.output_tokens,
|
||||
context_window,
|
||||
};
|
||||
let rate_limits = compose_rate_limit_data(rate_limits);
|
||||
|
||||
@@ -123,6 +142,22 @@ impl StatusHistoryCell {
|
||||
]
|
||||
}
|
||||
|
||||
fn context_window_spans(&self) -> Option<Vec<Span<'static>>> {
|
||||
let context = self.token_usage.context_window.as_ref()?;
|
||||
let percent = context.percent_remaining;
|
||||
let used_fmt = format_tokens_compact(context.tokens_in_context);
|
||||
let window_fmt = format_tokens_compact(context.window);
|
||||
|
||||
Some(vec![
|
||||
Span::from(format!("{percent}% left")),
|
||||
Span::from(" (").dim(),
|
||||
Span::from(used_fmt).dim(),
|
||||
Span::from(" / ").dim(),
|
||||
Span::from(window_fmt).dim(),
|
||||
Span::from(")").dim(),
|
||||
])
|
||||
}
|
||||
|
||||
fn rate_limit_lines(
|
||||
&self,
|
||||
available_inner_width: usize,
|
||||
@@ -235,6 +270,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
push_label(&mut labels, &mut seen, "Session");
|
||||
}
|
||||
push_label(&mut labels, &mut seen, "Token usage");
|
||||
if self.token_usage.context_window.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Context window");
|
||||
}
|
||||
self.collect_rate_limit_labels(&mut seen, &mut labels);
|
||||
|
||||
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
|
||||
@@ -266,6 +304,10 @@ impl HistoryCell for StatusHistoryCell {
|
||||
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
lines.push(formatter.line("Token usage", self.token_usage_spans()));
|
||||
|
||||
if let Some(spans) = self.context_window_spans() {
|
||||
lines.push(formatter.line("Context window", spans));
|
||||
}
|
||||
|
||||
lines.extend(self.rate_limit_lines(available_inner_width, &formatter));
|
||||
|
||||
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);
|
||||
|
||||
@@ -36,9 +36,13 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
|
||||
Ok(paths) => {
|
||||
let mut rels: Vec<String> = Vec::new();
|
||||
for p in paths {
|
||||
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 {
|
||||
"AGENTS.md".to_string()
|
||||
file_name.clone()
|
||||
} else {
|
||||
let mut cur = config.cwd.as_path();
|
||||
let mut ups = 0usize;
|
||||
@@ -53,7 +57,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
|
||||
}
|
||||
if reached {
|
||||
let up = format!("..{}", std::path::MAIN_SEPARATOR);
|
||||
format!("{}AGENTS.md", up.repeat(ups))
|
||||
format!("{}{}", up.repeat(ups), file_name)
|
||||
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
|
||||
stripped.display().to_string()
|
||||
} else {
|
||||
|
||||
+13
-12
@@ -4,15 +4,16 @@ expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
|
||||
╰───────────────────────────────────────────────────────────────────────────╯
|
||||
╭────────────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Context window: 100% left (1.2K / 272K) │
|
||||
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
|
||||
╰────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+14
-13
@@ -4,16 +4,17 @@ expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning high, summaries detailed) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: workspace-write │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
|
||||
╰───────────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning high, summaries detailed) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: workspace-write │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.1K / 272K) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+13
-12
@@ -4,15 +4,16 @@ expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭──────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Limits: data not available yet │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Context window: 100% left (750 / 272K) │
|
||||
│ Limits: data not available yet │
|
||||
╰─────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+13
-12
@@ -4,15 +4,16 @@ expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭──────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Limits: send a message to load usage data │
|
||||
╰──────────────────────────────────────────────────────────────╯
|
||||
╭─────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Context window: 100% left (750 / 272K) │
|
||||
│ Limits: send a message to load usage data │
|
||||
╰─────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+8
-7
@@ -7,13 +7,14 @@ expression: sanitized
|
||||
╭────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Model: gpt-5-codex (reasoning hig │
|
||||
│ Model: gpt-5-codex (reasoning │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 90 │
|
||||
│ 5h limit: [███████████████░░░░░] 72% │
|
||||
│ (resets 03:14) │
|
||||
│ Token usage: 1.9K total (1K input + │
|
||||
│ Context window: 100% left (2.1K / 272K) │
|
||||
│ 5h limit: [███████████████░░░░░] │
|
||||
│ (resets 03:14) │
|
||||
╰────────────────────────────────────────────╯
|
||||
|
||||
@@ -103,7 +103,7 @@ fn status_snapshot_includes_reasoning_details() {
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
@@ -144,7 +144,7 @@ fn status_snapshot_includes_monthly_limit() {
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
@@ -170,7 +170,7 @@ fn status_card_token_usage_excludes_cached_tokens() {
|
||||
total_tokens: 2_100,
|
||||
};
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, None);
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
|
||||
assert!(
|
||||
@@ -211,7 +211,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(46));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
@@ -238,7 +238,7 @@ fn status_snapshot_shows_missing_limits_message() {
|
||||
total_tokens: 750,
|
||||
};
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, None);
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
@@ -274,7 +274,7 @@ fn status_snapshot_shows_empty_limits_message() {
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let composite = new_status_output(&config, &usage, &None, Some(&rate_display));
|
||||
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
@@ -284,3 +284,41 @@ fn status_snapshot_shows_empty_limits_message() {
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_context_window_uses_last_usage() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home);
|
||||
config.model_context_window = Some(272_000);
|
||||
|
||||
let total_usage = TokenUsage {
|
||||
input_tokens: 12_800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 879,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 102_000,
|
||||
};
|
||||
let last_usage = TokenUsage {
|
||||
input_tokens: 12_800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 879,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 13_679,
|
||||
};
|
||||
|
||||
let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None);
|
||||
let rendered_lines = render_lines(&composite.display_lines(80));
|
||||
let context_line = rendered_lines
|
||||
.into_iter()
|
||||
.find(|line| line.contains("Context window"))
|
||||
.expect("context line");
|
||||
|
||||
assert!(
|
||||
context_line.contains("13.7K / 272K"),
|
||||
"expected context line to reflect last usage tokens, got: {context_line}"
|
||||
);
|
||||
assert!(
|
||||
!context_line.contains("102K"),
|
||||
"context line should not use total aggregated tokens, got: {context_line}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -705,6 +705,16 @@ This is analogous to `model_context_window`, but for the maximum number of outpu
|
||||
|
||||
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
|
||||
|
||||
## project_doc_fallback_filenames
|
||||
|
||||
Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time.
|
||||
|
||||
```toml
|
||||
project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"]
|
||||
```
|
||||
|
||||
We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance.
|
||||
|
||||
## tui
|
||||
|
||||
Options that are specific to the TUI.
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import eslint from '@eslint/js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslint from "@eslint/js";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
);
|
||||
export default defineConfig(eslint.configs.recommended, tseslint.configs.recommended);
|
||||
|
||||
@@ -6,7 +6,7 @@ export class Codex {
|
||||
private exec: CodexExec;
|
||||
private options: CodexOptions;
|
||||
|
||||
constructor(options: CodexOptions) {
|
||||
constructor(options: CodexOptions = {}) {
|
||||
this.exec = new CodexExec(options.codexPathOverride);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export type CodexOptions = {
|
||||
codexPathOverride?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,14 @@ export type CodexExecArgs = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
threadId?: string | null;
|
||||
// --model
|
||||
model?: string;
|
||||
// --sandbox
|
||||
sandboxMode?: SandboxMode;
|
||||
// --cd
|
||||
workingDirectory?: string;
|
||||
// --skip-git-repo-check
|
||||
skipGitRepoCheck?: boolean;
|
||||
};
|
||||
|
||||
export class CodexExec {
|
||||
@@ -33,12 +39,18 @@ export class CodexExec {
|
||||
commandArgs.push("--sandbox", args.sandboxMode);
|
||||
}
|
||||
|
||||
if (args.threadId) {
|
||||
commandArgs.push("resume", args.threadId, args.input);
|
||||
} else {
|
||||
commandArgs.push(args.input);
|
||||
if (args.workingDirectory) {
|
||||
commandArgs.push("--cd", args.workingDirectory);
|
||||
}
|
||||
|
||||
if (args.skipGitRepoCheck) {
|
||||
commandArgs.push("--skip-git-repo-check");
|
||||
}
|
||||
|
||||
if (args.threadId) {
|
||||
commandArgs.push("resume", args.threadId);
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
};
|
||||
@@ -55,11 +67,25 @@ export class CodexExec {
|
||||
|
||||
let spawnError: unknown | null = null;
|
||||
child.once("error", (err) => (spawnError = err));
|
||||
|
||||
if (!child.stdin) {
|
||||
child.kill();
|
||||
throw new Error("Child process has no stdin");
|
||||
}
|
||||
child.stdin.write(args.input);
|
||||
child.stdin.end();
|
||||
|
||||
if (!child.stdout) {
|
||||
child.kill();
|
||||
throw new Error("Child process has no stdout");
|
||||
}
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
stderrChunks.push(data);
|
||||
});
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout,
|
||||
@@ -72,12 +98,13 @@ export class CodexExec {
|
||||
yield line as string;
|
||||
}
|
||||
|
||||
const exitCode = new Promise((resolve) => {
|
||||
child.once("exit", (code) => {
|
||||
const exitCode = new Promise((resolve, reject) => {
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(code);
|
||||
} else {
|
||||
throw new Error(`Codex Exec exited with code ${code}`);
|
||||
const stderrBuffer = Buffer.concat(stderrChunks);
|
||||
reject(new Error(`Codex Exec exited with code ${code}: ${stderrBuffer.toString('utf8')}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ export class Thread {
|
||||
threadId: this.id,
|
||||
model: options?.model,
|
||||
sandboxMode: options?.sandboxMode,
|
||||
workingDirectory: options?.workingDirectory,
|
||||
skipGitRepoCheck: options?.skipGitRepoCheck,
|
||||
});
|
||||
for await (const item of generator) {
|
||||
const parsed = JSON.parse(item) as ThreadEvent;
|
||||
|
||||
@@ -5,4 +5,6 @@ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"
|
||||
export type TurnOptions = {
|
||||
model?: string;
|
||||
sandboxMode?: SandboxMode;
|
||||
workingDirectory?: string;
|
||||
skipGitRepoCheck?: boolean;
|
||||
};
|
||||
|
||||
@@ -9,8 +9,7 @@ const actualChildProcess = jest.requireActual<typeof import("child_process")>("c
|
||||
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
|
||||
|
||||
export function codexExecSpy(): { args: string[][]; restore: () => void } {
|
||||
const previousImplementation =
|
||||
spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
|
||||
const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
|
||||
const args: string[][] = [];
|
||||
|
||||
spawnMock.mockImplementation(((...spawnArgs: Parameters<typeof child_process.spawn>) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import { codexExecSpy } from "./codexExecSpy";
|
||||
@@ -211,14 +213,79 @@ describe("Codex", () => {
|
||||
|
||||
expectPair(commandArgs, ["--sandbox", "workspace-write"]);
|
||||
expectPair(commandArgs, ["--model", "gpt-test-1"]);
|
||||
|
||||
} finally {
|
||||
restore();
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("runs in provided working directory", async () => {
|
||||
const { url, close } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
responseBodies: [
|
||||
sse(
|
||||
responseStarted("response_1"),
|
||||
assistantMessage("Working directory applied", "item_1"),
|
||||
responseCompleted("response_1"),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { args: spawnArgs, restore } = codexExecSpy();
|
||||
|
||||
try {
|
||||
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
|
||||
const client = new Codex({
|
||||
codexPathOverride: codexExecPath,
|
||||
baseUrl: url,
|
||||
apiKey: "test",
|
||||
});
|
||||
|
||||
const thread = client.startThread();
|
||||
await thread.run("use custom working directory", {
|
||||
workingDirectory,
|
||||
skipGitRepoCheck: true,
|
||||
});
|
||||
|
||||
const commandArgs = spawnArgs[0];
|
||||
expectPair(commandArgs, ["--cd", workingDirectory]);
|
||||
} finally {
|
||||
restore();
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws if working directory is not git and no skipGitRepoCheck is provided", async () => {
|
||||
const { url, close } = await startResponsesTestProxy({
|
||||
statusCode: 200,
|
||||
responseBodies: [
|
||||
sse(
|
||||
responseStarted("response_1"),
|
||||
assistantMessage("Working directory applied", "item_1"),
|
||||
responseCompleted("response_1"),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
|
||||
const client = new Codex({
|
||||
codexPathOverride: codexExecPath,
|
||||
baseUrl: url,
|
||||
apiKey: "test",
|
||||
});
|
||||
|
||||
const thread = client.startThread();
|
||||
await expect(
|
||||
thread.run("use custom working directory", {
|
||||
workingDirectory,
|
||||
}),
|
||||
).rejects.toThrow(/Not inside a trusted directory/);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function expectPair(args: string[] | undefined, pair: [string, string]) {
|
||||
if (!args) {
|
||||
|
||||
Reference in New Issue
Block a user