mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
17 Commits
pr2500
...
fix-unicod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c5b9ef6b | ||
|
|
c6a470f573 | ||
|
|
c579ae41ae | ||
|
|
0d12380c3b | ||
|
|
1a1516a80b | ||
|
|
61bbabe7d9 | ||
|
|
8481eb4c6e | ||
|
|
0ad4e11c84 | ||
|
|
ee8c4ad23a | ||
|
|
202af12926 | ||
|
|
ce434b1219 | ||
|
|
d1f1e36836 | ||
|
|
eaae56a1b0 | ||
|
|
77148a5c61 | ||
|
|
17c98a7fd3 | ||
|
|
bc298e47ca | ||
|
|
0d6678936f |
4
.github/dependabot.yaml
vendored
4
.github/dependabot.yaml
vendored
@@ -24,3 +24,7 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: rust-toolchain
|
||||
directory: codex-rs
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -8,7 +8,7 @@ In the codex-rs folder where the rust code lives:
|
||||
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
|
||||
|
||||
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
|
||||
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests:
|
||||
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
|
||||
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
|
||||
|
||||
|
||||
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -926,6 +926,7 @@ name = "codex-tui"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -961,6 +962,7 @@ dependencies = [
|
||||
"supports-color",
|
||||
"textwrap 0.16.2",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@@ -1161,6 +1163,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
|
||||
@@ -22,6 +22,8 @@ use tree_sitter_bash::LANGUAGE as BASH;
|
||||
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
|
||||
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
|
||||
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ApplyPatchError {
|
||||
#[error(transparent)]
|
||||
@@ -82,7 +84,6 @@ pub struct ApplyPatchArgs {
|
||||
}
|
||||
|
||||
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
|
||||
match argv {
|
||||
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
|
||||
Ok(source) => MaybeApplyPatch::Body(source),
|
||||
@@ -91,7 +92,9 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
[bash, flag, script]
|
||||
if bash == "bash"
|
||||
&& flag == "-lc"
|
||||
&& script.trim_start().starts_with("apply_patch") =>
|
||||
&& APPLY_PATCH_COMMANDS
|
||||
.iter()
|
||||
.any(|cmd| script.trim_start().starts_with(cmd)) =>
|
||||
{
|
||||
match extract_heredoc_body_from_apply_patch_command(script) {
|
||||
Ok(body) => match parse_patch(&body) {
|
||||
@@ -262,7 +265,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
|
||||
fn extract_heredoc_body_from_apply_patch_command(
|
||||
src: &str,
|
||||
) -> std::result::Result<String, ExtractHeredocError> {
|
||||
if !src.trim_start().starts_with("apply_patch") {
|
||||
if !APPLY_PATCH_COMMANDS
|
||||
.iter()
|
||||
.any(|cmd| src.trim_start().starts_with(cmd))
|
||||
{
|
||||
return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch);
|
||||
}
|
||||
|
||||
@@ -773,6 +779,33 @@ PATCH"#,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_file_hunk_creates_file_with_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
46
codex-rs/common/src/approval_presets.rs
Normal file
46
codex-rs/common/src/approval_presets.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
|
||||
/// A simple preset pairing an approval policy with a sandbox policy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalPreset {
|
||||
/// Stable identifier for the preset.
|
||||
pub id: &'static str,
|
||||
/// Display label shown in UIs.
|
||||
pub label: &'static str,
|
||||
/// Short human description shown next to the label in UIs.
|
||||
pub description: &'static str,
|
||||
/// Approval policy to apply.
|
||||
pub approval: AskForApproval,
|
||||
/// Sandbox policy to apply.
|
||||
pub sandbox: SandboxPolicy,
|
||||
}
|
||||
|
||||
/// Built-in list of approval presets that pair approval and sandbox policy.
|
||||
///
|
||||
/// Keep this UI-agnostic so it can be reused by both TUI and MCP server.
|
||||
pub fn builtin_approval_presets() -> Vec<ApprovalPreset> {
|
||||
vec![
|
||||
ApprovalPreset {
|
||||
id: "read-only",
|
||||
label: "Read Only",
|
||||
description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network",
|
||||
approval: AskForApproval::OnRequest,
|
||||
sandbox: SandboxPolicy::ReadOnly,
|
||||
},
|
||||
ApprovalPreset {
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network",
|
||||
approval: AskForApproval::OnRequest,
|
||||
sandbox: SandboxPolicy::new_workspace_write_policy(),
|
||||
},
|
||||
ApprovalPreset {
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution",
|
||||
approval: AskForApproval::Never,
|
||||
sandbox: SandboxPolicy::DangerFullAccess,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -31,3 +31,6 @@ pub use config_summary::create_config_summary_entries;
|
||||
pub mod fuzzy_match;
|
||||
// Shared model presets used by TUI and MCP server
|
||||
pub mod model_presets;
|
||||
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
|
||||
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
|
||||
pub mod approval_presets;
|
||||
|
||||
@@ -24,28 +24,28 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] {
|
||||
ModelPreset {
|
||||
id: "gpt-5-minimal",
|
||||
label: "gpt-5 minimal",
|
||||
description: "— Fastest responses with very limited reasoning; ideal for coding, instructions, or lightweight tasks.",
|
||||
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Minimal,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-low",
|
||||
label: "gpt-5 low",
|
||||
description: "— Balances speed with some reasoning; useful for straightforward queries and short explanations.",
|
||||
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Low,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-medium",
|
||||
label: "gpt-5 medium",
|
||||
description: "— Default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks.",
|
||||
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::Medium,
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-high",
|
||||
label: "gpt-5 high",
|
||||
description: "— Maximizes reasoning depth for complex or ambiguous problems.",
|
||||
description: "— maximizes reasoning depth for complex or ambiguous problems",
|
||||
model: "gpt-5",
|
||||
effort: ReasoningEffort::High,
|
||||
},
|
||||
|
||||
@@ -300,6 +300,16 @@ This is reasonable to use if Codex is running in an environment that provides it
|
||||
|
||||
Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows.
|
||||
|
||||
## Approval presets
|
||||
|
||||
Codex provides three main Approval Presets:
|
||||
|
||||
- Read Only: Codex can read files and answer questions; edits, running commands, and network access require approval.
|
||||
- Auto: Codex can read files, make edits, and run commands in the workspace without approval; asks for approval outside the workspace or for network access.
|
||||
- Full Access: Full disk and network access without prompts; extremely risky.
|
||||
|
||||
You can further customize how Codex runs at the command line using the `--ask-for-approval` and `--sandbox` options.
|
||||
|
||||
## mcp_servers
|
||||
|
||||
Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy).
|
||||
|
||||
@@ -208,11 +208,7 @@ impl ModelClient {
|
||||
req_builder = req_builder.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let originator = self
|
||||
.config
|
||||
.internal_originator
|
||||
.as_deref()
|
||||
.unwrap_or("codex_cli_rs");
|
||||
let originator = &self.config.responses_originator_header;
|
||||
req_builder = req_builder.header("originator", originator);
|
||||
req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
|
||||
|
||||
@@ -252,6 +248,12 @@ impl ModelClient {
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
if status == StatusCode::UNAUTHORIZED
|
||||
&& let Some(a) = auth.as_ref()
|
||||
{
|
||||
let _ = a.refresh_token().await;
|
||||
}
|
||||
|
||||
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
|
||||
// errors. When we bubble early with only the HTTP status the caller sees an opaque
|
||||
// "unexpected status 400 Bad Request" which makes debugging nearly impossible.
|
||||
@@ -259,7 +261,10 @@ impl ModelClient {
|
||||
// exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is
|
||||
// small and this branch only runs on error paths so the extra allocation is
|
||||
// negligible.
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
|
||||
if !(status == StatusCode::TOO_MANY_REQUESTS
|
||||
|| status == StatusCode::UNAUTHORIZED
|
||||
|| status.is_server_error())
|
||||
{
|
||||
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(CodexErr::UnexpectedStatus(status, body));
|
||||
|
||||
@@ -35,6 +35,8 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
const DEFAULT_RESPONSES_ORIGINATOR_HEADER: &str = "codex_cli_rs";
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Config {
|
||||
@@ -163,7 +165,7 @@ pub struct Config {
|
||||
pub include_apply_patch_tool: bool,
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
pub responses_originator_header: String,
|
||||
|
||||
/// If set to `true`, the API key will be signed with the `originator` header.
|
||||
pub preferred_auth_method: AuthMode,
|
||||
@@ -410,7 +412,7 @@ pub struct ConfigToml {
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
|
||||
/// The value for the `originator` header included with Responses API requests.
|
||||
pub internal_originator: Option<String>,
|
||||
pub responses_originator_header_internal_override: Option<String>,
|
||||
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
@@ -625,6 +627,10 @@ impl Config {
|
||||
let include_apply_patch_tool_val =
|
||||
include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool);
|
||||
|
||||
let responses_originator_header: String = cfg
|
||||
.responses_originator_header_internal_override
|
||||
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
|
||||
|
||||
let config = Self {
|
||||
model,
|
||||
model_family,
|
||||
@@ -678,7 +684,7 @@ impl Config {
|
||||
experimental_resume,
|
||||
include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||
include_apply_patch_tool: include_apply_patch_tool_val,
|
||||
internal_originator: cfg.internal_originator,
|
||||
responses_originator_header,
|
||||
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
|
||||
};
|
||||
Ok(config)
|
||||
@@ -1043,7 +1049,7 @@ disable_response_storage = true
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
},
|
||||
o3_profile_config
|
||||
@@ -1096,7 +1102,7 @@ disable_response_storage = true
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
};
|
||||
|
||||
@@ -1164,7 +1170,7 @@ disable_response_storage = true
|
||||
base_instructions: None,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
internal_originator: None,
|
||||
responses_originator_header: "codex_cli_rs".to_string(),
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_protocol::mcp_protocol::GitSha;
|
||||
use futures::future::join_all;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::Duration as TokioDuration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::util::is_inside_git_repo;
|
||||
|
||||
/// Timeout for git commands to prevent freezing on large repositories
|
||||
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
|
||||
@@ -22,6 +27,12 @@ pub struct GitInfo {
|
||||
pub repository_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GitDiffToRemote {
|
||||
pub sha: GitSha,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
/// Collect git repository information from the given working directory using command-line git.
|
||||
/// Returns None if no git repository is found or if git operations fail.
|
||||
/// Uses timeouts to prevent freezing on large repositories.
|
||||
@@ -80,6 +91,23 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
||||
Some(git_info)
|
||||
}
|
||||
|
||||
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
||||
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
||||
if !is_inside_git_repo(cwd) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let remotes = get_git_remotes(cwd).await?;
|
||||
let branches = branch_ancestry(cwd).await?;
|
||||
let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
|
||||
let diff = diff_against_sha(cwd, &base_sha).await?;
|
||||
|
||||
Some(GitDiffToRemote {
|
||||
sha: base_sha,
|
||||
diff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a git command with a timeout to prevent blocking on large repositories
|
||||
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
||||
let result = timeout(
|
||||
@@ -94,6 +122,309 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
||||
let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
||||
let origin = remotes.remove(pos);
|
||||
remotes.insert(0, origin);
|
||||
}
|
||||
Some(remotes)
|
||||
}
|
||||
|
||||
/// Attempt to determine the repository's default branch name.
|
||||
///
|
||||
/// Preference order:
|
||||
/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
||||
/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
||||
/// 3) Local fallback to existing `main` or `master` if present
|
||||
async fn get_default_branch(cwd: &Path) -> Option<String> {
|
||||
// Prefer the first remote (with origin prioritized)
|
||||
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
||||
for remote in remotes {
|
||||
// Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
||||
if let Some(symref_output) = run_git_command_with_timeout(
|
||||
&[
|
||||
"symbolic-ref",
|
||||
"--quiet",
|
||||
&format!("refs/remotes/{remote}/HEAD"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& symref_output.status.success()
|
||||
&& let Ok(sym) = String::from_utf8(symref_output.stdout)
|
||||
{
|
||||
let trimmed = sym.trim();
|
||||
if let Some((_, name)) = trimmed.rsplit_once('/') {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to parsing `git remote show <remote>` output
|
||||
if let Some(show_output) =
|
||||
run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
||||
&& show_output.status.success()
|
||||
&& let Ok(text) = String::from_utf8(show_output.stdout)
|
||||
{
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
||||
let name = rest.trim();
|
||||
if !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No remote-derived default; try common local defaults if they exist
|
||||
for candidate in ["main", "master"] {
|
||||
if let Some(verify) = run_git_command_with_timeout(
|
||||
&[
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&format!("refs/heads/{candidate}"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& verify.status.success()
|
||||
{
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build an ancestry of branches starting at the current branch and ending at the
|
||||
/// repository's default branch (if determinable)..
|
||||
async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
||||
// Discover current branch (ignore detached HEAD by treating it as None)
|
||||
let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
||||
.await
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| s != "HEAD");
|
||||
|
||||
// Discover default branch
|
||||
let default_branch = get_default_branch(cwd).await;
|
||||
|
||||
let mut ancestry: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
if let Some(cb) = current_branch.clone() {
|
||||
seen.insert(cb.clone());
|
||||
ancestry.push(cb);
|
||||
}
|
||||
if let Some(db) = default_branch
|
||||
&& !seen.contains(&db)
|
||||
{
|
||||
seen.insert(db.clone());
|
||||
ancestry.push(db);
|
||||
}
|
||||
|
||||
// Expand candidates: include any remote branches that already contain HEAD.
|
||||
// This addresses cases where we're on a new local-only branch forked from a
|
||||
// remote branch that isn't the repository default. We prioritize remotes in
|
||||
// the order returned by get_git_remotes (origin first).
|
||||
let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
||||
for remote in remotes {
|
||||
if let Some(output) = run_git_command_with_timeout(
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"--contains=HEAD",
|
||||
&format!("refs/remotes/{remote}"),
|
||||
],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
&& output.status.success()
|
||||
&& let Ok(text) = String::from_utf8(output.stdout)
|
||||
{
|
||||
for line in text.lines() {
|
||||
let short = line.trim();
|
||||
// Expect format like: "origin/feature"; extract the branch path after "remote/"
|
||||
if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
|
||||
&& !stripped.is_empty()
|
||||
&& !seen.contains(stripped)
|
||||
{
|
||||
seen.insert(stripped.to_string());
|
||||
ancestry.push(stripped.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
||||
Some(ancestry)
|
||||
}
|
||||
|
||||
// Helper for a single branch: return the remote SHA if present on any remote
|
||||
// and the distance (commits ahead of HEAD) for that branch. The first item is
|
||||
// None if the branch is not present on any remote. Returns None if distance
|
||||
// could not be computed due to git errors/timeouts.
|
||||
async fn branch_remote_and_distance(
|
||||
cwd: &Path,
|
||||
branch: &str,
|
||||
remotes: &[String],
|
||||
) -> Option<(Option<GitSha>, usize)> {
|
||||
// Try to find the first remote ref that exists for this branch (origin prioritized by caller).
|
||||
let mut found_remote_sha: Option<GitSha> = None;
|
||||
let mut found_remote_ref: Option<String> = None;
|
||||
for remote in remotes {
|
||||
let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
||||
let Some(verify_output) =
|
||||
run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
|
||||
.await
|
||||
else {
|
||||
// Mirror previous behavior: if the verify call times out/fails at the process level,
|
||||
// treat the entire branch as unusable.
|
||||
return None;
|
||||
};
|
||||
if !verify_output.status.success() {
|
||||
continue;
|
||||
}
|
||||
let Ok(sha) = String::from_utf8(verify_output.stdout) else {
|
||||
// Mirror previous behavior and skip the entire branch on parse failure.
|
||||
return None;
|
||||
};
|
||||
found_remote_sha = Some(GitSha::new(sha.trim()));
|
||||
found_remote_ref = Some(remote_ref);
|
||||
break;
|
||||
}
|
||||
|
||||
// Compute distance as the number of commits HEAD is ahead of the branch.
|
||||
// Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
|
||||
let count_output = if let Some(local_count) =
|
||||
run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
|
||||
.await
|
||||
{
|
||||
if local_count.status.success() {
|
||||
local_count
|
||||
} else if let Some(remote_ref) = &found_remote_ref {
|
||||
match run_git_command_with_timeout(
|
||||
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(remote_count) => remote_count,
|
||||
None => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else if let Some(remote_ref) = &found_remote_ref {
|
||||
match run_git_command_with_timeout(
|
||||
&["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(remote_count) => remote_count,
|
||||
None => return None,
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if !count_output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
|
||||
return None;
|
||||
};
|
||||
let Ok(distance) = distance_str.trim().parse::<usize>() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some((found_remote_sha, distance))
|
||||
}
|
||||
|
||||
// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
|
||||
async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
|
||||
// A sha and how many commits away from HEAD it is.
|
||||
let mut closest_sha: Option<(GitSha, usize)> = None;
|
||||
for branch in branches {
|
||||
let Some((maybe_remote_sha, distance)) =
|
||||
branch_remote_and_distance(cwd, branch, remotes).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(remote_sha) = maybe_remote_sha else {
|
||||
// Preserve existing behavior: skip branches that are not present on a remote.
|
||||
continue;
|
||||
};
|
||||
match &closest_sha {
|
||||
None => closest_sha = Some((remote_sha, distance)),
|
||||
Some((_, best_distance)) if distance < *best_distance => {
|
||||
closest_sha = Some((remote_sha, distance));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
closest_sha.map(|(sha, _)| sha)
|
||||
}
|
||||
|
||||
async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
||||
let output = run_git_command_with_timeout(&["diff", &sha.0], cwd).await?;
|
||||
// 0 is success and no diff.
|
||||
// 1 is success but there is a diff.
|
||||
let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
|
||||
if !exit_ok {
|
||||
return None;
|
||||
}
|
||||
let mut diff = String::from_utf8(output.stdout).ok()?;
|
||||
|
||||
if let Some(untracked_output) =
|
||||
run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
|
||||
&& untracked_output.status.success()
|
||||
{
|
||||
let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
if !untracked.is_empty() {
|
||||
let futures_iter = untracked.into_iter().map(|file| async move {
|
||||
let file_owned = file;
|
||||
let args_vec: Vec<&str> =
|
||||
vec!["diff", "--binary", "--no-index", "/dev/null", &file_owned];
|
||||
run_git_command_with_timeout(&args_vec, cwd).await
|
||||
});
|
||||
let results = join_all(futures_iter).await;
|
||||
for extra in results.into_iter().flatten() {
|
||||
if extra.status.code().is_some_and(|c| c == 0 || c == 1)
|
||||
&& let Ok(s) = String::from_utf8(extra.stdout)
|
||||
{
|
||||
diff.push_str(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(diff)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -104,7 +435,8 @@ mod tests {
|
||||
|
||||
// Helper function to create a test git repository
|
||||
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
fs::create_dir(&repo_path).expect("Failed to create repo dir");
|
||||
let envs = vec![
|
||||
("GIT_CONFIG_GLOBAL", "/dev/null"),
|
||||
("GIT_CONFIG_NOSYSTEM", "1"),
|
||||
@@ -159,6 +491,41 @@ mod tests {
|
||||
repo_path
|
||||
}
|
||||
|
||||
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
||||
let repo_path = create_test_git_repo(temp_dir).await;
|
||||
let remote_path = temp_dir.path().join("remote.git");
|
||||
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", remote_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to init bare remote");
|
||||
|
||||
Command::new("git")
|
||||
.args(["remote", "add", "origin", remote_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to add remote");
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to get branch");
|
||||
let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
||||
|
||||
Command::new("git")
|
||||
.args(["push", "-u", "origin", &branch])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to push initial commit");
|
||||
|
||||
(repo_path, branch)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collect_git_info_non_git_directory() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -272,6 +639,136 @@ mod tests {
|
||||
assert_eq!(git_info.branch, Some("feature-branch".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_clean_repo() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_with_changes() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let tracked = repo_path.join("test.txt");
|
||||
fs::write(&tracked, "modified").unwrap();
|
||||
fs::write(repo_path.join("untracked.txt"), "new").unwrap();
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.contains("test.txt"));
|
||||
assert!(state.diff.contains("untracked.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_branch_fallback() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to create feature branch");
|
||||
Command::new("git")
|
||||
.args(["push", "-u", "origin", "feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to push feature branch");
|
||||
|
||||
Command::new("git")
|
||||
.args(["checkout", "-b", "local-branch"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to create local branch");
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", "origin/feature"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_git_working_tree_state_unpushed_commit() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
||||
|
||||
let remote_sha = Command::new("git")
|
||||
.args(["rev-parse", &format!("origin/{branch}")])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to rev-parse remote");
|
||||
let remote_sha = String::from_utf8(remote_sha.stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
fs::write(repo_path.join("test.txt"), "updated").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.txt"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to add file");
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "local change"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to commit");
|
||||
|
||||
let state = git_diff_to_remote(&repo_path)
|
||||
.await
|
||||
.expect("Should collect working tree state");
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
assert!(state.diff.contains("updated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_info_serialization() {
|
||||
let git_info = GitInfo {
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod spawn;
|
||||
pub mod terminal;
|
||||
pub mod turn_diff_tracker;
|
||||
pub mod user_agent;
|
||||
mod user_notification;
|
||||
|
||||
@@ -34,6 +34,8 @@ use crate::config_types::HistoryPersistence;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
/// Filename that stores the message history inside `~/.codex`.
|
||||
const HISTORY_FILENAME: &str = "history.jsonl";
|
||||
@@ -125,18 +127,17 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
|
||||
/// times if the lock is currently held by another process. This prevents a
|
||||
/// potential indefinite wait while still giving other writers some time to
|
||||
/// finish their operation.
|
||||
#[cfg(unix)]
|
||||
async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> {
|
||||
use tokio::time::sleep;
|
||||
|
||||
for _ in 0..MAX_RETRIES {
|
||||
match file.try_lock() {
|
||||
match try_flock_exclusive(file) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => match e {
|
||||
std::fs::TryLockError::WouldBlock => {
|
||||
sleep(RETRY_SLEEP).await;
|
||||
}
|
||||
other => return Err(other.into()),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
sleep(RETRY_SLEEP).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +147,12 @@ async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn acquire_exclusive_lock_with_retry(_file: &File) -> Result<()> {
|
||||
// On non-Unix, skip locking; appends are still atomic with O_APPEND.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Asynchronously fetch the history file's *identifier* (inode on Unix) and
|
||||
/// the current number of entries by counting newline characters.
|
||||
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
|
||||
@@ -261,14 +268,12 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
|
||||
#[cfg(unix)]
|
||||
fn acquire_shared_lock_with_retry(file: &File) -> Result<()> {
|
||||
for _ in 0..MAX_RETRIES {
|
||||
match file.try_lock_shared() {
|
||||
match try_flock_shared(file) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => match e {
|
||||
std::fs::TryLockError::WouldBlock => {
|
||||
std::thread::sleep(RETRY_SLEEP);
|
||||
}
|
||||
other => return Err(other.into()),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(RETRY_SLEEP);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +283,45 @@ fn acquire_shared_lock_with_retry(file: &File) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn acquire_shared_lock_with_retry(_file: &File) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn try_flock_exclusive(file: &File) -> Result<()> {
|
||||
let fd = file.as_raw_fd();
|
||||
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN => Err(
|
||||
std::io::Error::new(std::io::ErrorKind::WouldBlock, "lock would block"),
|
||||
),
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn try_flock_shared(file: &File) -> Result<()> {
|
||||
let fd = file.as_raw_fd();
|
||||
let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN => Err(
|
||||
std::io::Error::new(std::io::ErrorKind::WouldBlock, "lock would block"),
|
||||
),
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
|
||||
/// permissions cannot be changed the error is propagated to the caller.
|
||||
#[cfg(unix)]
|
||||
|
||||
72
codex-rs/core/src/terminal.rs
Normal file
72
codex-rs/core/src/terminal.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static TERMINAL: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn user_agent() -> String {
|
||||
TERMINAL.get_or_init(detect_terminal).to_string()
|
||||
}
|
||||
|
||||
/// Sanitize a header value to be used in a User-Agent string.
|
||||
///
|
||||
/// This function replaces any characters that are not allowed in a User-Agent string with an underscore.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - The value to sanitize.
|
||||
fn is_valid_header_value_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/'
|
||||
}
|
||||
|
||||
fn sanitize_header_value(value: String) -> String {
|
||||
value.replace(|c| !is_valid_header_value_char(c), "_")
|
||||
}
|
||||
|
||||
fn detect_terminal() -> String {
|
||||
sanitize_header_value(
|
||||
if let Ok(tp) = std::env::var("TERM_PROGRAM")
|
||||
&& !tp.trim().is_empty()
|
||||
{
|
||||
let ver = std::env::var("TERM_PROGRAM_VERSION").ok();
|
||||
match ver {
|
||||
Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"),
|
||||
_ => tp,
|
||||
}
|
||||
} else if let Ok(v) = std::env::var("WEZTERM_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("WezTerm/{v}")
|
||||
} else {
|
||||
"WezTerm".to_string()
|
||||
}
|
||||
} else if std::env::var("KITTY_WINDOW_ID").is_ok()
|
||||
|| std::env::var("TERM")
|
||||
.map(|t| t.contains("kitty"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"kitty".to_string()
|
||||
} else if std::env::var("ALACRITTY_SOCKET").is_ok()
|
||||
|| std::env::var("TERM")
|
||||
.map(|t| t == "alacritty")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"Alacritty".to_string()
|
||||
} else if let Ok(v) = std::env::var("KONSOLE_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("Konsole/{v}")
|
||||
} else {
|
||||
"Konsole".to_string()
|
||||
}
|
||||
} else if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() {
|
||||
return "gnome-terminal".to_string();
|
||||
} else if let Ok(v) = std::env::var("VTE_VERSION") {
|
||||
if !v.trim().is_empty() {
|
||||
format!("VTE/{v}")
|
||||
} else {
|
||||
"VTE".to_string()
|
||||
}
|
||||
} else if std::env::var("WT_SESSION").is_ok() {
|
||||
return "WindowsTerminal".to_string();
|
||||
} else {
|
||||
std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string())
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,12 @@ pub fn get_codex_user_agent(originator: Option<&str>) -> String {
|
||||
let build_version = env!("CARGO_PKG_VERSION");
|
||||
let os_info = os_info::get();
|
||||
format!(
|
||||
"{}/{build_version} ({} {}; {})",
|
||||
"{}/{build_version} ({} {}; {}) {}",
|
||||
originator.unwrap_or(DEFAULT_ORIGINATOR),
|
||||
os_info.os_type(),
|
||||
os_info.version(),
|
||||
os_info.architecture().unwrap_or("unknown"),
|
||||
crate::terminal::user_agent()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,9 +28,10 @@ mod tests {
|
||||
fn test_macos() {
|
||||
use regex_lite::Regex;
|
||||
let user_agent = get_codex_user_agent(None);
|
||||
let re =
|
||||
Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$")
|
||||
.unwrap();
|
||||
let re = Regex::new(
|
||||
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(re.is_match(&user_agent));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ async fn originator_config_override_is_used() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.internal_originator = Some("my_override".to_string());
|
||||
config.responses_originator_header = "my_override".to_owned();
|
||||
|
||||
let conversation_manager = ConversationManager::default();
|
||||
let codex = conversation_manager
|
||||
|
||||
@@ -24,8 +24,8 @@ file-search *args:
|
||||
fmt:
|
||||
cargo fmt -- --config imports_granularity=Item
|
||||
|
||||
fix:
|
||||
cargo clippy --fix --all-features --tests --allow-dirty
|
||||
fix *args:
|
||||
cargo clippy --fix --all-features --tests --allow-dirty "$@"
|
||||
|
||||
install:
|
||||
rustup show active-toolchain
|
||||
|
||||
@@ -62,6 +62,39 @@ impl CodexAuth {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
|
||||
let token_data = self
|
||||
.get_current_token_data()
|
||||
.ok_or(std::io::Error::other("Token data is not available."))?;
|
||||
let token = token_data.refresh_token;
|
||||
|
||||
let refresh_response = try_refresh_token(token)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
let updated = update_tokens(
|
||||
&self.auth_file,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Ok(mut auth_lock) = self.auth_dot_json.lock() {
|
||||
*auth_lock = Some(updated.clone());
|
||||
}
|
||||
|
||||
let access = match updated.tokens {
|
||||
Some(t) => t.access_token,
|
||||
None => {
|
||||
return Err(std::io::Error::other(
|
||||
"Token data is not available after refresh.",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Loads the available auth information from the auth.json or
|
||||
/// OPENAI_API_KEY environment variable.
|
||||
pub fn from_codex_home(
|
||||
@@ -209,7 +242,7 @@ fn load_auth(
|
||||
// "refreshable" even if we are using the API key for auth?
|
||||
match &tokens {
|
||||
Some(tokens) => {
|
||||
if tokens.should_use_api_key(preferred_auth_method) {
|
||||
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
} else {
|
||||
// Ignore the API key and fall through to ChatGPT auth.
|
||||
|
||||
@@ -25,16 +25,31 @@ pub struct TokenData {
|
||||
impl TokenData {
|
||||
/// Returns true if this is a plan that should use the traditional
|
||||
/// "metered" billing via an API key.
|
||||
pub(crate) fn should_use_api_key(&self, preferred_auth_method: AuthMode) -> bool {
|
||||
pub(crate) fn should_use_api_key(
|
||||
&self,
|
||||
preferred_auth_method: AuthMode,
|
||||
is_openai_email: bool,
|
||||
) -> bool {
|
||||
if preferred_auth_method == AuthMode::ApiKey {
|
||||
return true;
|
||||
}
|
||||
// If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey.
|
||||
if is_openai_email {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.id_token
|
||||
.chatgpt_plan_type
|
||||
.as_ref()
|
||||
.is_none_or(|plan| plan.is_plan_that_should_use_api_key())
|
||||
}
|
||||
|
||||
pub fn is_openai_email(&self) -> bool {
|
||||
self.id_token
|
||||
.email
|
||||
.as_deref()
|
||||
.is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat subset of useful claims in id_token from auth.json.
|
||||
|
||||
@@ -8,11 +8,13 @@ use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::git_info::git_diff_to_remote;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
|
||||
use mcp_types::JSONRPCErrorError;
|
||||
use mcp_types::RequestId;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -126,6 +128,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::CancelLoginChatGpt { request_id, params } => {
|
||||
self.cancel_login_chatgpt(request_id, params.login_id).await;
|
||||
}
|
||||
ClientRequest::GitDiffToRemote { request_id, params } => {
|
||||
self.git_diff_to_origin(request_id, params.cwd).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +519,27 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) {
|
||||
let diff = git_diff_to_remote(&cwd).await;
|
||||
match diff {
|
||||
Some(value) => {
|
||||
let response = GitDiffToRemoteResponse {
|
||||
sha: value.sha,
|
||||
diff: value.diff,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
None => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("failed to compute git diff to remote for cwd: {cwd:?}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_bespoke_event_handling(
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
|
||||
|
||||
@@ -26,6 +26,16 @@ impl Display for ConversationId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
|
||||
#[ts(type = "string")]
|
||||
pub struct GitSha(pub String);
|
||||
|
||||
impl GitSha {
|
||||
pub fn new(sha: &str) -> Self {
|
||||
Self(sha.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Request from the client to the server.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(tag = "method", rename_all = "camelCase")]
|
||||
@@ -69,6 +79,11 @@ pub enum ClientRequest {
|
||||
request_id: RequestId,
|
||||
params: CancelLoginChatGptParams,
|
||||
},
|
||||
GitDiffToRemote {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: GitDiffToRemoteParams,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
|
||||
@@ -139,6 +154,13 @@ pub struct LoginChatGptResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitDiffToRemoteResponse {
|
||||
pub sha: GitSha,
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
// Event name for notifying client of login completion or failure.
|
||||
pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete";
|
||||
|
||||
@@ -157,6 +179,12 @@ pub struct CancelLoginChatGptParams {
|
||||
pub login_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitDiffToRemoteParams {
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelLoginChatGptResponse {}
|
||||
|
||||
@@ -22,6 +22,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-stream = "0.3.6"
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -33,18 +34,19 @@ codex-common = { path = "../common", features = [
|
||||
"sandbox_summary",
|
||||
] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
codex-file-search = { path = "../file-search" }
|
||||
codex-login = { path = "../login" }
|
||||
codex-ollama = { path = "../ollama" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] }
|
||||
diffy = "0.4.2"
|
||||
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
once_cell = "1"
|
||||
path-clean = "1.0.1"
|
||||
rand = "0.9"
|
||||
ratatui = { version = "0.29.0", features = [
|
||||
"scrolling-regions",
|
||||
"unstable-rendered-line-info",
|
||||
@@ -67,6 +69,7 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tokio-stream = "0.1.17"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
@@ -75,7 +78,6 @@ tui-markdown = "0.3.3"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
rand = "0.9"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,714 +1,335 @@
|
||||
use crate::LoginStatus;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::get_login_status;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreen;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::terminal::supports_keyboard_enhancement;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::channel;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
/// Time window for debouncing redraw requests.
|
||||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
|
||||
|
||||
/// Top-level application state: which full-screen view is currently active.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum AppState<'a> {
|
||||
Onboarding {
|
||||
screen: OnboardingScreen,
|
||||
},
|
||||
/// The main chat UI is visible.
|
||||
Chat {
|
||||
/// Boxed to avoid a large enum variant and reduce the overall size of
|
||||
/// `AppState`.
|
||||
widget: Box<ChatWidget<'a>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct App<'a> {
|
||||
pub(crate) struct App {
|
||||
server: Arc<ConversationManager>,
|
||||
app_event_tx: AppEventSender,
|
||||
app_event_rx: Receiver<AppEvent>,
|
||||
app_state: AppState<'a>,
|
||||
chat_widget: ChatWidget,
|
||||
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
config: Config,
|
||||
|
||||
file_search: FileSearchManager,
|
||||
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
commit_anim_running: Arc<AtomicBool>,
|
||||
|
||||
/// Channel to schedule one-shot animation frames; coalesced by a single
|
||||
/// scheduler thread.
|
||||
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
|
||||
}
|
||||
|
||||
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
/// deferred until after the Git warning screen is dismissed.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatWidgetArgs {
|
||||
pub(crate) config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
enhanced_keys_supported: bool,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
impl App {
|
||||
pub async fn run(
|
||||
tui: &mut tui::Tui,
|
||||
config: Config,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
show_trust_screen: bool,
|
||||
) -> Self {
|
||||
let conversation_manager = Arc::new(ConversationManager::default());
|
||||
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
initial_images: Vec<PathBuf>,
|
||||
) -> Result<TokenUsage> {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
|
||||
let conversation_manager = Arc::new(ConversationManager::default());
|
||||
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
// re-publishing the events as AppEvents, as appropriate.
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
// This timeout is necessary to avoid holding the event lock
|
||||
// that crossterm::event::read() acquires. In particular,
|
||||
// reading the cursor position (crossterm::cursor::position())
|
||||
// needs to acquire the event lock, and so will fail if it
|
||||
// can't acquire it within 2 sec. Resizing the terminal
|
||||
// crashes the app if the cursor position can't be read.
|
||||
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
|
||||
if let Ok(event) = crossterm::event::read() {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
crossterm::event::Event::Paste(pasted) => {
|
||||
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
||||
// but tui-textarea expects \n. Normalize CR to LF.
|
||||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
app_event_tx.send(AppEvent::Paste(pasted));
|
||||
}
|
||||
_ => {
|
||||
// Ignore any other events.
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Timeout expired, no `Event` is available
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let login_status = get_login_status(&config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &config, show_trust_screen);
|
||||
let app_state = if should_show_onboarding {
|
||||
let show_login_screen = should_show_login_screen(login_status, &config);
|
||||
let chat_widget_args = ChatWidgetArgs {
|
||||
config: config.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
};
|
||||
AppState::Onboarding {
|
||||
screen: OnboardingScreen::new(OnboardingScreenArgs {
|
||||
event_tx: app_event_tx.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
show_trust_screen,
|
||||
show_login_screen,
|
||||
chat_widget_args,
|
||||
login_status,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
let chat_widget = ChatWidget::new(
|
||||
config.clone(),
|
||||
conversation_manager.clone(),
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
);
|
||||
AppState::Chat {
|
||||
widget: Box::new(chat_widget),
|
||||
}
|
||||
};
|
||||
let chat_widget = ChatWidget::new(
|
||||
config.clone(),
|
||||
conversation_manager.clone(),
|
||||
tui.frame_requester(),
|
||||
app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
);
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
|
||||
// Spawn a single scheduler thread that coalesces both debounced redraw
|
||||
// requests and animation frame requests, and emits a single Redraw event
|
||||
// at the earliest requested time.
|
||||
let (frame_tx, frame_rx) = channel::<Instant>();
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::sync::mpsc::RecvTimeoutError;
|
||||
let mut next_deadline: Option<Instant> = None;
|
||||
loop {
|
||||
if next_deadline.is_none() {
|
||||
match frame_rx.recv() {
|
||||
Ok(deadline) => next_deadline = Some(deadline),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let deadline = next_deadline.expect("deadline set");
|
||||
let now = Instant::now();
|
||||
let timeout = if deadline > now {
|
||||
deadline - now
|
||||
} else {
|
||||
Duration::from_millis(0)
|
||||
};
|
||||
|
||||
match frame_rx.recv_timeout(timeout) {
|
||||
Ok(new_deadline) => {
|
||||
next_deadline =
|
||||
Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline)));
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
app_event_tx.send(AppEvent::Redraw);
|
||||
next_deadline = None;
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Self {
|
||||
let mut app = Self {
|
||||
server: conversation_manager,
|
||||
app_event_tx,
|
||||
pending_history_lines: Vec::new(),
|
||||
app_event_rx,
|
||||
app_state,
|
||||
chat_widget,
|
||||
config,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
frame_schedule_tx: frame_tx,
|
||||
}
|
||||
};
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
||||
while select! {
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
app.handle_event(tui, event)?
|
||||
}
|
||||
Some(event) = tui_events.next() => {
|
||||
app.handle_tui_event(tui, event).await?
|
||||
}
|
||||
} {}
|
||||
tui.terminal.clear()?;
|
||||
Ok(app.token_usage())
|
||||
}
|
||||
|
||||
fn schedule_frame_in(&self, dur: Duration) {
|
||||
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
|
||||
}
|
||||
|
||||
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
// Schedule the first render immediately.
|
||||
let _ = self.frame_schedule_tx.send(Instant::now());
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::RequestRedraw => {
|
||||
self.schedule_frame_in(REDRAW_DEBOUNCE);
|
||||
}
|
||||
AppEvent::ScheduleFrameIn(dur) => {
|
||||
self.schedule_frame_in(dur);
|
||||
}
|
||||
AppEvent::Redraw => {
|
||||
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
if self
|
||||
.commit_anim_running
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let tx = self.app_event_tx.clone();
|
||||
let running = self.commit_anim_running.clone();
|
||||
thread::spawn(move || {
|
||||
while running.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
tx.send(AppEvent::CommitTick);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::StopCommitAnimation => {
|
||||
self.commit_anim_running.store(false, Ordering::Release);
|
||||
}
|
||||
AppEvent::CommitTick => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.on_commit_tick();
|
||||
}
|
||||
}
|
||||
AppEvent::KeyEvent(key_event) => {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.on_ctrl_c();
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
},
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('z'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.suspend(terminal)?;
|
||||
}
|
||||
// No-op on non-Unix platforms.
|
||||
pub(crate) async fn handle_tui_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
self.handle_key_event(key_event).await;
|
||||
}
|
||||
TuiEvent::Paste(pasted) => {
|
||||
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
||||
// but tui-textarea expects \n. Normalize CR to LF.
|
||||
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
||||
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(
|
||||
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
||||
|frame| {
|
||||
frame.render_widget_ref(&self.chat_widget, frame.area());
|
||||
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if widget.composer_is_empty() {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
} else {
|
||||
// Treat Ctrl+D as a normal key event when the composer
|
||||
// is not empty so that it doesn't quit the application
|
||||
// prematurely.
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
}
|
||||
};
|
||||
}
|
||||
AppEvent::Paste(text) => {
|
||||
self.dispatch_paste_event(text);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
break;
|
||||
}
|
||||
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
AppEvent::DiffResult(text) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_diff_output(text);
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::New => {
|
||||
// User accepted – switch to chat view.
|
||||
let new_widget = Box::new(ChatWidget::new(
|
||||
self.config.clone(),
|
||||
self.server.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
self.enhanced_keys_supported,
|
||||
));
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
widget.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.open_model_popup();
|
||||
}
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_login::logout(&self.config.codex_home) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_diff_in_progress();
|
||||
}
|
||||
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let text = match get_git_diff().await {
|
||||
Ok((is_git_repo, diff_text)) => {
|
||||
if is_git_repo {
|
||||
diff_text
|
||||
} else {
|
||||
"`/diff` — _not inside a git repository_".to_string()
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Failed to compute diff: {e}"),
|
||||
};
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::Mention => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.insert_str("@");
|
||||
}
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_status_output();
|
||||
}
|
||||
}
|
||||
SlashCommand::Mcp => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_mcp_output();
|
||||
}
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
id: "1".to_string(),
|
||||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
// call_id: "1".to_string(),
|
||||
// command: vec!["git".into(), "apply".into()],
|
||||
// cwd: self.config.cwd.clone(),
|
||||
// reason: Some("test".to_string()),
|
||||
// }),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(
|
||||
ApplyPatchApprovalRequestEvent {
|
||||
call_id: "1".to_string(),
|
||||
changes: HashMap::from([
|
||||
(
|
||||
PathBuf::from("/tmp/test.txt"),
|
||||
FileChange::Add {
|
||||
content: "test".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("/tmp/test2.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: "+test\n-test2".to_string(),
|
||||
move_path: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
reason: None,
|
||||
grant_root: Some(PathBuf::from("/tmp")),
|
||||
},
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
AppEvent::OnboardingAuthComplete(result) => {
|
||||
if let AppState::Onboarding { screen } = &mut self.app_state {
|
||||
screen.on_auth_complete(result);
|
||||
}
|
||||
}
|
||||
AppEvent::OnboardingComplete(ChatWidgetArgs {
|
||||
config,
|
||||
enhanced_keys_supported,
|
||||
initial_images,
|
||||
initial_prompt,
|
||||
}) => {
|
||||
self.app_state = AppState::Chat {
|
||||
widget: Box::new(ChatWidget::new(
|
||||
config,
|
||||
self.server.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
)),
|
||||
}
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.set_model(model);
|
||||
}
|
||||
}
|
||||
},
|
||||
)?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
TuiEvent::ResumeFromSuspend => {
|
||||
let cursor_pos = tui.terminal.get_cursor_position()?;
|
||||
tui.terminal
|
||||
.set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0));
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
tui::restore()?;
|
||||
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
|
||||
// current process group (pid 0) to trigger standard job-control
|
||||
// suspension semantics. This FFI does not involve any raw pointers,
|
||||
// is not called from a signal handler, and uses a constant signal.
|
||||
// Errors from kill are acceptable (e.g., if already stopped) — the
|
||||
// subsequent re-init path will still leave the terminal in a good state.
|
||||
// We considered `nix`, but didn't think it was worth pulling in for this one call.
|
||||
unsafe { libc::kill(0, libc::SIGTSTP) };
|
||||
*terminal = tui::init(&self.config)?;
|
||||
terminal.clear()?;
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
Ok(())
|
||||
fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
AppEvent::StartCommitAnimation => {
|
||||
if self
|
||||
.commit_anim_running
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let tx = self.app_event_tx.clone();
|
||||
let running = self.commit_anim_running.clone();
|
||||
thread::spawn(move || {
|
||||
while running.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
tx.send(AppEvent::CommitTick);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::StopCommitAnimation => {
|
||||
self.commit_anim_running.store(false, Ordering::Release);
|
||||
}
|
||||
AppEvent::CommitTick => {
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
|
||||
AppEvent::DiffResult(text) => {
|
||||
self.chat_widget.add_diff_output(text);
|
||||
}
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::New => {
|
||||
// User accepted – switch to chat view.
|
||||
let new_widget = ChatWidget::new(
|
||||
self.config.clone(),
|
||||
self.server.clone(),
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
self.chat_widget = new_widget;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
self.chat_widget
|
||||
.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
self.chat_widget.clear_token_usage();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.chat_widget.open_model_popup();
|
||||
}
|
||||
SlashCommand::Approvals => {
|
||||
self.chat_widget.open_approvals_popup();
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
return Ok(false);
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_login::logout(&self.config.codex_home) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.chat_widget.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let text = match get_git_diff().await {
|
||||
Ok((is_git_repo, diff_text)) => {
|
||||
if is_git_repo {
|
||||
diff_text
|
||||
} else {
|
||||
"`/diff` — _not inside a git repository_".to_string()
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Failed to compute diff: {e}"),
|
||||
};
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::Mention => {
|
||||
self.chat_widget.insert_str("@");
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
self.chat_widget.add_status_output();
|
||||
}
|
||||
SlashCommand::Mcp => {
|
||||
self.chat_widget.add_mcp_output();
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use codex_core::protocol::EventMsg;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||
id: "1".to_string(),
|
||||
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
// call_id: "1".to_string(),
|
||||
// command: vec!["git".into(), "apply".into()],
|
||||
// cwd: self.config.cwd.clone(),
|
||||
// reason: Some("test".to_string()),
|
||||
// }),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
call_id: "1".to_string(),
|
||||
changes: HashMap::from([
|
||||
(
|
||||
PathBuf::from("/tmp/test.txt"),
|
||||
FileChange::Add {
|
||||
content: "test".to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("/tmp/test2.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: "+test\n-test2".to_string(),
|
||||
move_path: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
reason: None,
|
||||
grant_root: Some(PathBuf::from("/tmp")),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
self.chat_widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(model);
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||
match &self.app_state {
|
||||
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
|
||||
}
|
||||
self.chat_widget.token_usage().clone()
|
||||
}
|
||||
|
||||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
if matches!(self.app_state, AppState::Onboarding { .. }) {
|
||||
terminal.clear()?;
|
||||
}
|
||||
|
||||
let screen_size = terminal.size()?;
|
||||
let last_known_screen_size = terminal.last_known_screen_size;
|
||||
if screen_size != last_known_screen_size {
|
||||
let cursor_pos = terminal.get_cursor_position()?;
|
||||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||
if cursor_pos.y != last_known_cursor_pos.y {
|
||||
// The terminal was resized. The only point of reference we have for where our viewport
|
||||
// was moved is the cursor position.
|
||||
// NB this assumes that the cursor was not wrapped as part of the resize.
|
||||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||
|
||||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||
x: 0,
|
||||
y: cursor_delta,
|
||||
});
|
||||
terminal.set_viewport_area(new_viewport_area);
|
||||
terminal.clear()?;
|
||||
async fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.chat_widget.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.chat_widget.composer_is_empty() => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
// Ignore Release key events.
|
||||
}
|
||||
}
|
||||
|
||||
let size = terminal.size()?;
|
||||
let desired_height = match &self.app_state {
|
||||
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||
AppState::Onboarding { .. } => size.height,
|
||||
};
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
area.height = desired_height.min(size.height);
|
||||
area.width = size.width;
|
||||
if area.bottom() > size.height {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||||
area.y = size.height - area.height;
|
||||
}
|
||||
if area != terminal.viewport_area {
|
||||
terminal.clear()?;
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
if !self.pending_history_lines.is_empty() {
|
||||
crate::insert_history::insert_history_lines(
|
||||
terminal,
|
||||
self.pending_history_lines.clone(),
|
||||
);
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
terminal.draw(|frame| match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
frame.render_widget_ref(&**widget, frame.area())
|
||||
}
|
||||
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a KeyEvent to the current view and let it decide what to do
|
||||
/// with it.
|
||||
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
AppState::Onboarding { screen } => match key_event.code {
|
||||
KeyCode::Char('q') => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
_ => screen.handle_key_event(key_event),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
AppState::Onboarding { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
AppState::Onboarding { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_show_onboarding(
|
||||
login_status: LoginStatus,
|
||||
config: &Config,
|
||||
show_trust_screen: bool,
|
||||
) -> bool {
|
||||
if show_trust_screen {
|
||||
return true;
|
||||
}
|
||||
|
||||
should_show_login_screen(login_status, config)
|
||||
}
|
||||
|
||||
fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool {
|
||||
match login_status {
|
||||
LoginStatus::NotAuthenticated => true,
|
||||
LoginStatus::AuthMode(method) => method != config.preferred_auth_method,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_login::AuthMode;
|
||||
|
||||
fn make_config(preferred: AuthMode) -> Config {
|
||||
let mut cfg = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("load default config");
|
||||
cfg.preferred_auth_method = preferred;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_not_authenticated() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::NotAuthenticated,
|
||||
&cfg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_api_key_but_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_api_key_and_prefers_api_key() {
|
||||
let cfg = make_config(AuthMode::ApiKey);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_chatgpt_and_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ChatGPT),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -13,21 +12,6 @@ use codex_core::protocol_config_types::ReasoningEffort;
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
|
||||
/// Request a redraw which will be debounced by the [`App`].
|
||||
RequestRedraw,
|
||||
|
||||
/// Actually draw the next frame.
|
||||
Redraw,
|
||||
|
||||
/// Schedule a one-shot animation frame roughly after the given duration.
|
||||
/// Multiple requests are coalesced by the central frame scheduler.
|
||||
ScheduleFrameIn(Duration),
|
||||
|
||||
KeyEvent(KeyEvent),
|
||||
|
||||
/// Text pasted from the terminal clipboard.
|
||||
Paste(String),
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
|
||||
@@ -61,13 +45,15 @@ pub(crate) enum AppEvent {
|
||||
StopCommitAnimation,
|
||||
CommitTick,
|
||||
|
||||
/// Onboarding: result of login_with_chatgpt.
|
||||
OnboardingAuthComplete(Result<(), String>),
|
||||
OnboardingComplete(ChatWidgetArgs),
|
||||
|
||||
/// Update the current reasoning effort in the running app and widget.
|
||||
UpdateReasoningEffort(ReasoningEffort),
|
||||
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the current approval policy in the running app and widget.
|
||||
UpdateAskForApprovalPolicy(AskForApproval),
|
||||
|
||||
/// Update the current sandbox policy in the running app and widget.
|
||||
UpdateSandboxPolicy(SandboxPolicy),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::session_log;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppEventSender {
|
||||
pub app_event_tx: Sender<AppEvent>,
|
||||
pub app_event_tx: UnboundedSender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventSender {
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
|
||||
Self { app_event_tx }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ use super::BottomPaneView;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Modal overlay asking the user to approve/deny a sequence of requests.
|
||||
pub(crate) struct ApprovalModalView<'a> {
|
||||
current: UserApprovalWidget<'a>,
|
||||
pub(crate) struct ApprovalModalView {
|
||||
current: UserApprovalWidget,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ApprovalModalView<'_> {
|
||||
impl ApprovalModalView {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
current: UserApprovalWidget::new(request, app_event_tx.clone()),
|
||||
@@ -41,13 +41,13 @@ impl ApprovalModalView<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
impl BottomPaneView for ApprovalModalView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
self.current.on_ctrl_c();
|
||||
self.queue.clear();
|
||||
CancellationEvent::Handled
|
||||
@@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -87,15 +87,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let first = make_exec_request();
|
||||
let mut view = ApprovalModalView::new(first, tx);
|
||||
view.enqueue_request(make_exec_request());
|
||||
|
||||
let (tx_raw2, _rx2) = channel::<AppEvent>();
|
||||
let (tx2, _rx2) = unbounded_channel::<AppEvent>();
|
||||
let mut pane = BottomPane::new(super::super::BottomPaneParams {
|
||||
app_event_tx: AppEventSender::new(tx_raw2),
|
||||
app_event_tx: AppEventSender::new(tx2),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
|
||||
@@ -7,10 +7,10 @@ use super::BottomPane;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||
pub(crate) trait BottomPaneView<'a> {
|
||||
pub(crate) trait BottomPaneView {
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {}
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {}
|
||||
|
||||
/// Return `true` if the view has finished and should be removed.
|
||||
fn is_complete(&self) -> bool {
|
||||
@@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C while this view is active.
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
CancellationEvent::Ignored
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,11 @@ impl ChatComposer {
|
||||
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
}
|
||||
// After completing the command, move cursor to the end.
|
||||
if !self.textarea.text().is_empty() {
|
||||
let end = self.textarea.text().len();
|
||||
self.textarea.set_cursor(end);
|
||||
}
|
||||
}
|
||||
(InputResult::None, true)
|
||||
}
|
||||
@@ -745,6 +750,7 @@ mod tests {
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn test_current_at_token_basic_cases() {
|
||||
@@ -901,7 +907,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -925,7 +931,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -955,7 +961,7 @@ mod tests {
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -977,7 +983,7 @@ mod tests {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
Ok(t) => t,
|
||||
@@ -1033,9 +1039,9 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1078,7 +1084,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1099,9 +1105,9 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1141,7 +1147,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1215,7 +1221,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
@@ -1282,7 +1288,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer =
|
||||
ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
|
||||
|
||||
@@ -192,7 +192,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn duplicate_submissions_are_not_recorded() {
|
||||
@@ -219,7 +219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn navigation_with_async_fetch() {
|
||||
let (tx, rx) = channel::<AppEvent>();
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
|
||||
let mut history = ChatComposerHistory::new();
|
||||
|
||||
@@ -105,8 +105,8 @@ impl ListSelectionView {
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for ListSelectionView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
@@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent {
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -39,15 +39,17 @@ pub(crate) use list_selection_view::SelectionItem;
|
||||
use status_indicator_view::StatusIndicatorView;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane<'a> {
|
||||
pub(crate) struct BottomPane {
|
||||
/// Composer is retained even when a BottomPaneView is displayed so the
|
||||
/// input state is retained when the view is closed.
|
||||
composer: ChatComposer,
|
||||
|
||||
/// If present, this is displayed instead of the `composer`.
|
||||
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
|
||||
active_view: Option<Box<dyn BottomPaneView>>,
|
||||
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
@@ -59,12 +61,13 @@ pub(crate) struct BottomPane<'a> {
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) frame_requester: FrameRequester,
|
||||
pub(crate) has_input_focus: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) placeholder_text: String,
|
||||
}
|
||||
|
||||
impl BottomPane<'_> {
|
||||
impl BottomPane {
|
||||
const BOTTOM_PAD_LINES: u16 = 2;
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
let enhanced_keys_supported = params.enhanced_keys_supported;
|
||||
@@ -77,6 +80,7 @@ impl BottomPane<'_> {
|
||||
),
|
||||
active_view: None,
|
||||
app_event_tx: params.app_event_tx,
|
||||
frame_requester: params.frame_requester,
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
@@ -113,7 +117,10 @@ impl BottomPane<'_> {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
let mut v = StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
);
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
@@ -144,7 +151,10 @@ impl BottomPane<'_> {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
// Modal aborted but task still running – restore status indicator.
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
let mut v = StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
);
|
||||
v.update_text("waiting for model".to_string());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
@@ -199,6 +209,7 @@ impl BottomPane<'_> {
|
||||
if self.active_view.is_none() {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
)));
|
||||
self.status_view_active = true;
|
||||
}
|
||||
@@ -292,7 +303,7 @@ impl BottomPane<'_> {
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
// --- History helpers ---
|
||||
@@ -322,7 +333,7 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
impl WidgetRef for &BottomPane {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if let Some(view) = &self.active_view {
|
||||
// Reserve bottom padding lines; keep at least 1 line for the view.
|
||||
@@ -359,7 +370,7 @@ mod tests {
|
||||
use crate::app_event::AppEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -371,10 +382,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -389,10 +401,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn overlay_not_shown_above_approval_modal() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -418,10 +431,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn composer_not_shown_after_denied_if_task_running() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -468,10 +482,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_indicator_visible_during_command_execution() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -500,10 +515,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_present_for_status_view() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -552,10 +568,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_shrinks_when_tiny() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
use super::BottomPaneView;
|
||||
|
||||
@@ -14,9 +15,9 @@ pub(crate) struct StatusIndicatorView {
|
||||
}
|
||||
|
||||
impl StatusIndicatorView {
|
||||
pub fn new(app_event_tx: AppEventSender) -> Self {
|
||||
pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
view: StatusIndicatorWidget::new(app_event_tx),
|
||||
view: StatusIndicatorWidget::new(app_event_tx, frame_requester),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +26,7 @@ impl StatusIndicatorView {
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
impl BottomPaneView for StatusIndicatorView {
|
||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -38,7 +39,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
self.view.render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
|
||||
if key_event.code == KeyCode::Esc {
|
||||
self.view.interrupt();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::tui::FrameRequester;
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
mod interrupts;
|
||||
@@ -60,9 +61,13 @@ mod agent;
|
||||
use self::agent::spawn_agent;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_common::model_presets::builtin_model_presets;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_file_search::FileMatch;
|
||||
use uuid::Uuid;
|
||||
@@ -73,10 +78,10 @@ struct RunningCommand {
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
bottom_pane: BottomPane,
|
||||
active_exec_cell: Option<ExecCell>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
@@ -94,6 +99,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
// Whether a redraw is needed after handling the current event
|
||||
needs_redraw: bool,
|
||||
session_id: Option<Uuid>,
|
||||
frame_requester: FrameRequester,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -120,7 +126,7 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
impl ChatWidget {
|
||||
#[inline]
|
||||
fn mark_needs_redraw(&mut self) {
|
||||
self.needs_redraw = true;
|
||||
@@ -496,6 +502,7 @@ impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
frame_requester: FrameRequester,
|
||||
app_event_tx: AppEventSender,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -507,8 +514,10 @@ impl ChatWidget<'_> {
|
||||
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
@@ -668,7 +677,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_in_progress(&mut self) {
|
||||
@@ -733,6 +742,57 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
|
||||
pub(crate) fn open_approvals_popup(&mut self) {
|
||||
let current_approval = self.config.approval_policy;
|
||||
let current_sandbox = self.config.sandbox_policy.clone();
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
|
||||
for preset in presets.into_iter() {
|
||||
let is_current =
|
||||
current_approval == preset.approval && current_sandbox == preset.sandbox;
|
||||
let approval = preset.approval;
|
||||
let sandbox = preset.sandbox.clone();
|
||||
let name = preset.label.to_string();
|
||||
let description = Some(preset.description.to_string());
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(approval),
|
||||
sandbox_policy: Some(sandbox.clone()),
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
}));
|
||||
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
|
||||
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));
|
||||
})];
|
||||
items.push(SelectionItem {
|
||||
name,
|
||||
description,
|
||||
is_current,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
||||
self.bottom_pane.show_selection_view(
|
||||
"Select Approval Mode".to_string(),
|
||||
None,
|
||||
Some("Press Enter to confirm or Esc to go back".to_string()),
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the approval policy in the widget's config copy.
|
||||
pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) {
|
||||
self.config.approval_policy = policy;
|
||||
}
|
||||
|
||||
/// Set the sandbox policy in the widget's config copy.
|
||||
pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) {
|
||||
self.config.sandbox_policy = policy;
|
||||
}
|
||||
|
||||
/// Set the reasoning effort in the widget's config copy.
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: ReasoningEffortConfig) {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
@@ -825,7 +885,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
impl WidgetRef for &ChatWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
|
||||
@@ -71,7 +71,7 @@ impl InterruptManager {
|
||||
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) {
|
||||
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) {
|
||||
while let Some(q) = self.queue.pop_front() {
|
||||
match q {
|
||||
QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev),
|
||||
|
||||
@@ -30,7 +30,6 @@ use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn test_config() -> Config {
|
||||
@@ -45,7 +44,7 @@ fn test_config() -> Config {
|
||||
|
||||
#[test]
|
||||
fn final_answer_without_newline_is_flushed_immediately() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
@@ -73,7 +72,7 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
});
|
||||
|
||||
// Drain history insertions and verify the final line is present.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
cells.iter().any(|lines| {
|
||||
let s = lines
|
||||
@@ -101,27 +100,36 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let cfg = test_config();
|
||||
let conversation_manager = Arc::new(ConversationManager::default());
|
||||
let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false);
|
||||
let mut w = ChatWidget::new(
|
||||
cfg,
|
||||
conversation_manager,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
tx,
|
||||
None,
|
||||
Vec::new(),
|
||||
false,
|
||||
);
|
||||
// Basic construction sanity.
|
||||
let _ = &mut w;
|
||||
}
|
||||
|
||||
// --- Helpers for tests that need direct construction and event draining ---
|
||||
fn make_chatwidget_manual() -> (
|
||||
ChatWidget<'static>,
|
||||
std::sync::mpsc::Receiver<AppEvent>,
|
||||
ChatWidget,
|
||||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||||
) {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, rx) = unbounded_channel::<AppEvent>();
|
||||
let app_event_tx = AppEventSender::new(tx_raw);
|
||||
let (op_tx, op_rx) = unbounded_channel::<Op>();
|
||||
let cfg = test_config();
|
||||
let bottom = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
@@ -143,12 +151,13 @@ fn make_chatwidget_manual() -> (
|
||||
interrupts: InterruptManager::new(),
|
||||
needs_redraw: false,
|
||||
session_id: None,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
fn drain_insert_history(
|
||||
rx: &std::sync::mpsc::Receiver<AppEvent>,
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Vec<Vec<ratatui::text::Line<'static>>> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
@@ -196,7 +205,7 @@ fn open_fixture(name: &str) -> std::fs::File {
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_shows_working_then_completed() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -226,7 +235,7 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
@@ -241,7 +250,7 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_shows_working_then_failed() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -271,7 +280,7 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
@@ -286,7 +295,7 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
@@ -423,7 +432,7 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_events_emit_history_cells() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Approval request -> proposed patch summary cell
|
||||
let mut changes = HashMap::new();
|
||||
@@ -443,7 +452,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -468,7 +477,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyBegin(begin),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected applying patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -487,7 +496,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::PatchApplyEnd(end),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected applied patch cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -498,7 +507,7 @@ fn apply_patch_events_emit_history_cells() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_approval_sends_op_with_submission_id() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
// Simulate receiving an approval request with a distinct submission id and call id
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
@@ -539,7 +548,7 @@ fn apply_patch_approval_sends_op_with_submission_id() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_full_flow_integration_like() {
|
||||
let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Backend requests approval
|
||||
let mut changes = HashMap::new();
|
||||
@@ -655,7 +664,7 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
|
||||
#[test]
|
||||
fn apply_patch_request_shows_diff_summary() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Ensure we are in OnRequest so an approval is surfaced
|
||||
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
|
||||
@@ -680,7 +689,7 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
});
|
||||
|
||||
// Drain history insertions and verify the diff summary is present
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
!cells.is_empty(),
|
||||
"expected a history cell with the proposed patch summary"
|
||||
@@ -695,14 +704,14 @@ fn apply_patch_request_shows_diff_summary() {
|
||||
|
||||
// Per-file summary line should include the file path and counts
|
||||
assert!(
|
||||
blob.contains("README.md (+2 -0)"),
|
||||
blob.contains("README.md"),
|
||||
"missing per-file diff summary: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_renders_history_cell() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
let update = UpdatePlanArgs {
|
||||
explanation: Some("Adapting plan".to_string()),
|
||||
plan: vec![
|
||||
@@ -724,7 +733,7 @@ fn plan_update_renders_history_cell() {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::PlanUpdate(update),
|
||||
});
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected plan update cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
@@ -738,7 +747,7 @@ fn plan_update_renders_history_cell() {
|
||||
|
||||
#[test]
|
||||
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Answer: no header until a newline commit
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -796,7 +805,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
);
|
||||
|
||||
// Reasoning: header immediately
|
||||
let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
|
||||
let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
|
||||
chat2.handle_codex_event(Event {
|
||||
id: "sub-b".into(),
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
@@ -826,7 +835,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
|
||||
#[test]
|
||||
fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin turn
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -858,7 +867,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let mut header_count = 0usize;
|
||||
let mut combined = String::new();
|
||||
for lines in &cells {
|
||||
@@ -894,7 +903,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
|
||||
#[test]
|
||||
fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// No deltas; only final reasoning followed by final message.
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -911,7 +920,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
});
|
||||
|
||||
// Drain history and snapshot the combined visible content.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
@@ -921,7 +930,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
|
||||
#[test]
|
||||
fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Stream some reasoning deltas first.
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -972,7 +981,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
|
||||
// Snapshot the combined visible content to ensure we render as expected
|
||||
// when deltas are followed by the identical final message.
|
||||
let cells = drain_insert_history(&rx);
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
|
||||
@@ -245,7 +245,7 @@ where
|
||||
/// Index of the current buffer in the previous array
|
||||
current: usize,
|
||||
/// Whether the cursor is currently hidden
|
||||
hidden_cursor: bool,
|
||||
pub hidden_cursor: bool,
|
||||
/// Area of the viewport
|
||||
pub viewport_area: Rect,
|
||||
/// Last known size of the terminal. Used to detect if the internal buffers have to be resized.
|
||||
|
||||
@@ -130,17 +130,20 @@ pub(crate) fn create_diff_summary(
|
||||
for (idx, f) in files.iter().enumerate() {
|
||||
let mut spans: Vec<RtSpan<'static>> = Vec::new();
|
||||
spans.push(RtSpan::raw(f.display_path.clone()));
|
||||
spans.push(RtSpan::raw(" ("));
|
||||
spans.push(RtSpan::styled(
|
||||
format!("+{}", f.added),
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
spans.push(RtSpan::raw(" "));
|
||||
spans.push(RtSpan::styled(
|
||||
format!("-{}", f.removed),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
spans.push(RtSpan::raw(")"));
|
||||
// Show per-file +/- counts only when there are multiple files
|
||||
if file_count > 1 {
|
||||
spans.push(RtSpan::raw(" ("));
|
||||
spans.push(RtSpan::styled(
|
||||
format!("+{}", f.added),
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
spans.push(RtSpan::raw(" "));
|
||||
spans.push(RtSpan::styled(
|
||||
format!("-{}", f.removed),
|
||||
Style::default().fg(Color::Red),
|
||||
));
|
||||
spans.push(RtSpan::raw(")"));
|
||||
}
|
||||
|
||||
let mut line = RtLine::from(spans);
|
||||
let prefix = if idx == 0 { " └ " } else { " " };
|
||||
|
||||
61
codex-rs/tui/src/emoji_width.rs
Normal file
61
codex-rs/tui/src/emoji_width.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::io::Write;
|
||||
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::style::Print;
|
||||
use ratatui::crossterm::terminal::Clear;
|
||||
use ratatui::crossterm::terminal::ClearType;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
static EMOJI_OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
|
||||
/// Returns true if common emoji we use advance the cursor by the same number of
|
||||
/// columns as reported by `unicode-width` on this terminal. The value is cached.
|
||||
pub fn emojis_render_as_expected() -> bool {
|
||||
// Optimistic default: render emoji unless we have measured otherwise.
|
||||
EMOJI_OK.get().copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Ensure that emoji width has been probed and cached. Call during TUI init.
|
||||
pub fn ensure_probed() {
|
||||
let _ = EMOJI_OK.get_or_init(detect);
|
||||
}
|
||||
|
||||
/// Run a small runtime probe by printing a few glyphs at (0,0) and reading the
|
||||
/// cursor position. If any measured width differs from `unicode-width`, we
|
||||
/// conclude emoji rendering is unreliable and the UI should fall back to ASCII.
|
||||
pub fn detect() -> bool {
|
||||
// Only probe a small curated set that we actually use in the UI.
|
||||
const TESTS: &[&str] = &["📂", "📖", "🔎", "🧪", "⚡", "⚙︎", "✏︎", "✓", "✗", "🖐"];
|
||||
|
||||
let mut out = std::io::stdout();
|
||||
// Best effort: on error, default to false (use ASCII) to avoid broken layout.
|
||||
let _ = execute!(out, Clear(ClearType::All));
|
||||
for s in TESTS {
|
||||
let expected = s.width();
|
||||
// Move to origin, print, flush, read cursor position.
|
||||
if execute!(out, ratatui::crossterm::cursor::MoveTo(0, 0), Print(*s)).is_err() {
|
||||
return false;
|
||||
}
|
||||
if out.flush().is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok((x, _y)) = ratatui::crossterm::cursor::position() else {
|
||||
return false;
|
||||
};
|
||||
if x as usize != expected {
|
||||
// Clear the line before returning.
|
||||
let _ = execute!(
|
||||
out,
|
||||
ratatui::crossterm::cursor::MoveTo(0, 0),
|
||||
Clear(ClearType::CurrentLine)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let _ = execute!(
|
||||
out,
|
||||
ratatui::crossterm::cursor::MoveTo(0, 0),
|
||||
Clear(ClearType::All)
|
||||
);
|
||||
true
|
||||
}
|
||||
@@ -168,7 +168,8 @@ pub(crate) fn new_session_info(
|
||||
Line::from("".dim()),
|
||||
Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()),
|
||||
Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()),
|
||||
Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()),
|
||||
Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()),
|
||||
Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()),
|
||||
Line::from("".dim()),
|
||||
];
|
||||
PlainHistoryCell { lines }
|
||||
@@ -243,7 +244,7 @@ fn new_parsed_command(
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
match output {
|
||||
None => {
|
||||
let mut spans = vec!["⚙︎ Working".magenta().bold()];
|
||||
let mut spans = vec![crate::icons::working().magenta().bold()];
|
||||
if let Some(st) = start_time {
|
||||
let dur = exec_duration(st);
|
||||
spans.push(format!(" • {dur}").dim());
|
||||
@@ -251,11 +252,14 @@ fn new_parsed_command(
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
Some(o) if o.exit_code == 0 => {
|
||||
lines.push(Line::from(vec!["✓".green(), " Completed".into()]));
|
||||
lines.push(Line::from(vec![
|
||||
crate::icons::completed_label().green(),
|
||||
" Completed".into(),
|
||||
]));
|
||||
}
|
||||
Some(o) => {
|
||||
lines.push(Line::from(vec![
|
||||
"✗".red(),
|
||||
crate::icons::failed_label().red(),
|
||||
format!(" Failed (exit {})", o.exit_code).into(),
|
||||
]));
|
||||
}
|
||||
@@ -281,22 +285,22 @@ fn new_parsed_command(
|
||||
|
||||
for (i, parsed) in parsed_commands.iter().enumerate() {
|
||||
let text = match parsed {
|
||||
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
|
||||
ParsedCommand::Read { name, .. } => format!("{} {name}", crate::icons::book()),
|
||||
ParsedCommand::ListFiles { cmd, path } => match path {
|
||||
Some(p) => format!("📂 {p}"),
|
||||
None => format!("📂 {cmd}"),
|
||||
Some(p) => format!("{} {p}", crate::icons::folder()),
|
||||
None => format!("{} {cmd}", crate::icons::folder()),
|
||||
},
|
||||
ParsedCommand::Search { query, path, cmd } => match (query, path) {
|
||||
(Some(q), Some(p)) => format!("🔎 {q} in {p}"),
|
||||
(Some(q), None) => format!("🔎 {q}"),
|
||||
(None, Some(p)) => format!("🔎 {p}"),
|
||||
(None, None) => format!("🔎 {cmd}"),
|
||||
(Some(q), Some(p)) => format!("{} {q} in {p}", crate::icons::search()),
|
||||
(Some(q), None) => format!("{} {q}", crate::icons::search()),
|
||||
(None, Some(p)) => format!("{} {p}", crate::icons::search()),
|
||||
(None, None) => format!("{} {cmd}", crate::icons::search()),
|
||||
},
|
||||
ParsedCommand::Format { .. } => "✨ Formatting".to_string(),
|
||||
ParsedCommand::Test { cmd } => format!("🧪 {cmd}"),
|
||||
ParsedCommand::Lint { cmd, .. } => format!("🧹 {cmd}"),
|
||||
ParsedCommand::Unknown { cmd } => format!("⌨️ {cmd}"),
|
||||
ParsedCommand::Noop { cmd } => format!("🔄 {cmd}"),
|
||||
ParsedCommand::Format { .. } => format!("{} Formatting", crate::icons::formatting()),
|
||||
ParsedCommand::Test { cmd } => format!("{} {cmd}", crate::icons::test()),
|
||||
ParsedCommand::Lint { cmd, .. } => format!("{} {cmd}", crate::icons::lint()),
|
||||
ParsedCommand::Unknown { cmd } => format!("{} {cmd}", crate::icons::keyboard_cmd()),
|
||||
ParsedCommand::Noop { cmd } => format!("{} {cmd}", crate::icons::noop()),
|
||||
};
|
||||
|
||||
let first_prefix = if i == 0 { " └ " } else { " " };
|
||||
@@ -324,7 +328,7 @@ fn new_exec_command_generic(
|
||||
let command_escaped = strip_bash_lc_and_escape(command);
|
||||
let mut cmd_lines = command_escaped.lines();
|
||||
if let Some(first) = cmd_lines.next() {
|
||||
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()];
|
||||
let mut spans: Vec<Span> = vec![crate::icons::running().magenta()];
|
||||
if let Some(st) = start_time {
|
||||
let dur = exec_duration(st);
|
||||
spans.push(format!(" • {dur}").dim());
|
||||
@@ -513,7 +517,11 @@ pub(crate) fn new_status_output(
|
||||
};
|
||||
|
||||
// 📂 Workspace
|
||||
lines.push(Line::from(vec!["📂 ".into(), "Workspace".bold()]));
|
||||
lines.push(Line::from(vec![
|
||||
crate::icons::workspace().into(),
|
||||
" ".into(),
|
||||
"Workspace".bold(),
|
||||
]));
|
||||
// Path (home-relative, e.g., ~/code/project)
|
||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
||||
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
|
||||
@@ -544,7 +552,11 @@ pub(crate) fn new_status_output(
|
||||
if let Ok(auth) = try_read_auth_json(&auth_file)
|
||||
&& let Some(tokens) = auth.tokens.clone()
|
||||
{
|
||||
lines.push(Line::from(vec!["👤 ".into(), "Account".bold()]));
|
||||
lines.push(Line::from(vec![
|
||||
crate::icons::account().into(),
|
||||
" ".into(),
|
||||
"Account".bold(),
|
||||
]));
|
||||
lines.push(Line::from(" • Signed in with ChatGPT"));
|
||||
|
||||
let info = tokens.id_token;
|
||||
@@ -571,7 +583,11 @@ pub(crate) fn new_status_output(
|
||||
}
|
||||
|
||||
// 🧠 Model
|
||||
lines.push(Line::from(vec!["🧠 ".into(), "Model".bold()]));
|
||||
lines.push(Line::from(vec![
|
||||
crate::icons::model().into(),
|
||||
" ".into(),
|
||||
"Model".bold(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" • Name: ".into(),
|
||||
config.model.clone().into(),
|
||||
@@ -600,7 +616,11 @@ pub(crate) fn new_status_output(
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// 📊 Token Usage
|
||||
lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()]));
|
||||
lines.push(Line::from(vec![
|
||||
crate::icons::token_usage().into(),
|
||||
" ".into(),
|
||||
"Token Usage".bold(),
|
||||
]));
|
||||
if let Some(session_id) = session_id {
|
||||
lines.push(Line::from(vec![
|
||||
" • Session ID: ".into(),
|
||||
@@ -714,7 +734,14 @@ pub(crate) fn new_mcp_tools_output(
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
|
||||
let lines: Vec<Line<'static>> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()];
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
vec![
|
||||
format!("{} ", crate::icons::wave_error()).red().bold(),
|
||||
message.into(),
|
||||
]
|
||||
.into(),
|
||||
"".into(),
|
||||
];
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
@@ -739,7 +766,7 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
let mut header: Vec<Span> = Vec::new();
|
||||
header.push(Span::raw("📋"));
|
||||
header.push(Span::raw(crate::icons::clipboard()));
|
||||
header.push(Span::styled(
|
||||
" Update plan",
|
||||
Style::default().add_modifier(Modifier::BOLD).magenta(),
|
||||
@@ -829,12 +856,12 @@ pub(crate) fn new_patch_event(
|
||||
PatchEventType::ApprovalRequest => "proposed patch",
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
} => "✏️ Applying patch",
|
||||
} => crate::icons::apply_patch(),
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: false,
|
||||
} => {
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from("✏️ Applying patch".magenta().bold()),
|
||||
Line::from(crate::icons::apply_patch().magenta().bold()),
|
||||
Line::from(""),
|
||||
];
|
||||
return PlainHistoryCell { lines };
|
||||
|
||||
156
codex-rs/tui/src/icons.rs
Normal file
156
codex-rs/tui/src/icons.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
/// Icons used throughout the TUI. We fall back to ASCII-safe variants
|
||||
/// when runtime detection shows emoji cell widths are inconsistent with
|
||||
/// what we use for wrapping.
|
||||
use crate::emoji_width::emojis_render_as_expected;
|
||||
|
||||
pub fn running() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⚡ Running"
|
||||
} else {
|
||||
"> Running"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn working() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⚙︎ Working"
|
||||
} else {
|
||||
"* Working"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn completed_label() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✓"
|
||||
} else {
|
||||
"OK"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed_label() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✗"
|
||||
} else {
|
||||
"X"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn folder() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📂"
|
||||
} else {
|
||||
"[dir]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn book() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📖"
|
||||
} else {
|
||||
"[read]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🔎"
|
||||
} else {
|
||||
"[find]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formatting() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✨"
|
||||
} else {
|
||||
"[fmt]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧪"
|
||||
} else {
|
||||
"[test]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lint() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧹"
|
||||
} else {
|
||||
"[lint]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyboard_cmd() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⌨︎"
|
||||
} else {
|
||||
"[cmd]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn noop() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🔄"
|
||||
} else {
|
||||
"[noop]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clipboard() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📋"
|
||||
} else {
|
||||
"[plan]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_patch() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✏︎ Applying patch"
|
||||
} else {
|
||||
"Applying patch"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📂"
|
||||
} else {
|
||||
"[dir]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"👤"
|
||||
} else {
|
||||
"[acct]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧠"
|
||||
} else {
|
||||
"[model]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn token_usage() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📊"
|
||||
} else {
|
||||
"[tokens]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wave_error() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🖐"
|
||||
} else {
|
||||
"[!]"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use textwrap::Options as TwOptions;
|
||||
use textwrap::WordSplitter;
|
||||
|
||||
/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec<Line>) {
|
||||
let mut out = std::io::stdout();
|
||||
insert_history_lines_to_writer(terminal, &mut out, lines);
|
||||
}
|
||||
@@ -38,9 +38,8 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
W: Write,
|
||||
{
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
let cursor_pos = terminal.get_cursor_position().ok();
|
||||
|
||||
let mut area = terminal.get_frame().area();
|
||||
let mut area = terminal.viewport_area;
|
||||
|
||||
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
|
||||
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
|
||||
@@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
queue!(writer, ResetScrollRegion).ok();
|
||||
|
||||
// Restore the cursor position to where it was before we started.
|
||||
if let Some(cursor_pos) = cursor_pos {
|
||||
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
|
||||
}
|
||||
queue!(
|
||||
writer,
|
||||
MoveTo(
|
||||
terminal.last_known_cursor_pos.x,
|
||||
terminal.last_known_cursor_pos.y
|
||||
)
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -33,10 +33,12 @@ mod cli;
|
||||
mod common;
|
||||
pub mod custom_terminal;
|
||||
mod diff_render;
|
||||
mod emoji_width;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
mod history_cell;
|
||||
mod icons;
|
||||
pub mod insert_history;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
@@ -64,6 +66,11 @@ use color_eyre::owo_colors::OwoColorize;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
use crate::onboarding::TrustDirectorySelection;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::onboarding::onboarding_screen::run_onboarding_app;
|
||||
use crate::tui::Tui;
|
||||
|
||||
// (tests access modules directly within the crate)
|
||||
|
||||
pub async fn run_main(
|
||||
@@ -247,14 +254,16 @@ pub async fn run_main(
|
||||
}
|
||||
|
||||
run_ratatui_app(cli, config, should_show_trust_screen)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
async fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
config: Config,
|
||||
should_show_trust_screen: bool,
|
||||
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||||
let mut config = config;
|
||||
color_eyre::install()?;
|
||||
|
||||
// Forward panic reports through tracing so they appear in the UI status
|
||||
@@ -266,23 +275,44 @@ fn run_ratatui_app(
|
||||
tracing::error!("panic: {info}");
|
||||
prev_hook(info);
|
||||
}));
|
||||
let mut terminal = tui::init(&config)?;
|
||||
let mut terminal = tui::init()?;
|
||||
terminal.clear()?;
|
||||
let mut tui = Tui::new(terminal);
|
||||
|
||||
// Initialize high-fidelity session event logging if enabled.
|
||||
session_log::maybe_init(&config);
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
|
||||
|
||||
let app_result = app.run(&mut terminal);
|
||||
let usage = app.token_usage();
|
||||
let login_status = get_login_status(&config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &config, should_show_trust_screen);
|
||||
if should_show_onboarding {
|
||||
let directory_trust_decision = run_onboarding_app(
|
||||
OnboardingScreenArgs {
|
||||
codex_home: config.codex_home.clone(),
|
||||
cwd: config.cwd.clone(),
|
||||
show_login_screen: should_show_login_screen(login_status, &config),
|
||||
show_trust_screen: should_show_trust_screen,
|
||||
login_status,
|
||||
preferred_auth_method: config.preferred_auth_method,
|
||||
},
|
||||
&mut tui,
|
||||
)
|
||||
.await?;
|
||||
if let Some(TrustDirectorySelection::Trust) = directory_trust_decision {
|
||||
config.approval_policy = AskForApproval::OnRequest;
|
||||
config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
}
|
||||
}
|
||||
|
||||
let app_result = App::run(&mut tui, config, prompt, images).await;
|
||||
|
||||
restore();
|
||||
// Mark the end of the recorded session.
|
||||
session_log::log_session_end();
|
||||
// ignore error when collecting usage – report underlying error instead
|
||||
app_result.map(|_| usage)
|
||||
app_result
|
||||
}
|
||||
|
||||
#[expect(
|
||||
@@ -356,3 +386,80 @@ fn determine_repo_trust_state(
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_show_onboarding(
|
||||
login_status: LoginStatus,
|
||||
config: &Config,
|
||||
show_trust_screen: bool,
|
||||
) -> bool {
|
||||
if show_trust_screen {
|
||||
return true;
|
||||
}
|
||||
|
||||
should_show_login_screen(login_status, config)
|
||||
}
|
||||
|
||||
fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool {
|
||||
// Only show the login screen for providers that actually require OpenAI auth
|
||||
// (OpenAI or equivalents). For OSS/other providers, skip login entirely.
|
||||
if !config.model_provider.requires_openai_auth {
|
||||
return false;
|
||||
}
|
||||
|
||||
match login_status {
|
||||
LoginStatus::NotAuthenticated => true,
|
||||
LoginStatus::AuthMode(method) => method != config.preferred_auth_method,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config(preferred: AuthMode) -> Config {
|
||||
let mut cfg = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("load default config");
|
||||
cfg.preferred_auth_method = preferred;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_not_authenticated() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::NotAuthenticated,
|
||||
&cfg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_api_key_but_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_api_key_and_prefers_api_key() {
|
||||
let cfg = make_config(AuthMode::ApiKey);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_chatgpt_and_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ChatGPT),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_login::CLIENT_ID;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::ShutdownHandle;
|
||||
@@ -18,19 +20,19 @@ use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use codex_login::AuthMode;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
// no additional imports
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum SignInState {
|
||||
PickMode,
|
||||
ChatGptContinueInBrowser(ContinueInBrowserState),
|
||||
@@ -40,18 +42,17 @@ pub(crate) enum SignInState {
|
||||
EnvVarFound,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone)]
|
||||
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
|
||||
pub(crate) struct ContinueInBrowserState {
|
||||
auth_url: String,
|
||||
shutdown_handle: Option<ShutdownHandle>,
|
||||
_login_wait_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
shutdown_flag: Option<ShutdownHandle>,
|
||||
}
|
||||
|
||||
impl Drop for ContinueInBrowserState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(flag) = &self.shutdown_handle {
|
||||
flag.shutdown();
|
||||
if let Some(handle) = &self.shutdown_flag {
|
||||
handle.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,20 +70,32 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
KeyCode::Char('2') => self.verify_api_key(),
|
||||
KeyCode::Enter => match self.sign_in_state {
|
||||
SignInState::PickMode => match self.highlighted_mode {
|
||||
AuthMode::ChatGPT => self.start_chatgpt_login(),
|
||||
AuthMode::ApiKey => self.verify_api_key(),
|
||||
},
|
||||
SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode,
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
self.sign_in_state = SignInState::ChatGptSuccess
|
||||
KeyCode::Enter => {
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
match sign_in_state {
|
||||
SignInState::PickMode => match self.highlighted_mode {
|
||||
AuthMode::ChatGPT => {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
AuthMode::ApiKey => {
|
||||
self.verify_api_key();
|
||||
}
|
||||
},
|
||||
SignInState::EnvVarMissing => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
||||
self.sign_in_state = SignInState::PickMode;
|
||||
tracing::info!("Esc pressed");
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -90,12 +103,12 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AuthModeWidget {
|
||||
pub event_tx: AppEventSender,
|
||||
pub request_frame: FrameRequester,
|
||||
pub highlighted_mode: AuthMode,
|
||||
pub error: Option<String>,
|
||||
pub sign_in_state: SignInState,
|
||||
pub sign_in_state: Arc<RwLock<SignInState>>,
|
||||
pub codex_home: PathBuf,
|
||||
pub login_status: LoginStatus,
|
||||
pub preferred_auth_method: AuthMode,
|
||||
@@ -215,14 +228,13 @@ impl AuthModeWidget {
|
||||
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut spans = vec![Span::from("> ")];
|
||||
// Schedule a follow-up frame to keep the shimmer animation going.
|
||||
self.event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
|
||||
100,
|
||||
)));
|
||||
self.request_frame
|
||||
.schedule_frame_in(std::time::Duration::from_millis(100));
|
||||
spans.extend(shimmer_spans("Finish signing in via your browser"));
|
||||
let mut lines = vec![Line::from(spans), Line::from("")];
|
||||
|
||||
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state
|
||||
&& !state.auth_url.is_empty()
|
||||
{
|
||||
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
|
||||
@@ -315,35 +327,45 @@ impl AuthModeWidget {
|
||||
// If we're already authenticated with ChatGPT, don't start a new login –
|
||||
// just proceed to the success message flow.
|
||||
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
|
||||
self.sign_in_state = SignInState::ChatGptSuccess;
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
self.request_frame.schedule_frame();
|
||||
return;
|
||||
}
|
||||
|
||||
self.error = None;
|
||||
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
|
||||
let server = run_login_server(opts);
|
||||
match server {
|
||||
match run_login_server(opts) {
|
||||
Ok(child) => {
|
||||
let auth_url = child.auth_url.clone();
|
||||
let shutdown_handle = child.cancel_handle();
|
||||
|
||||
let event_tx = self.event_tx.clone();
|
||||
let join_handle = tokio::spawn(async move {
|
||||
spawn_completion_poller(child, event_tx).await;
|
||||
let sign_in_state = self.sign_in_state.clone();
|
||||
let request_frame = self.request_frame.clone();
|
||||
tokio::spawn(async move {
|
||||
let auth_url = child.auth_url.clone();
|
||||
{
|
||||
*sign_in_state.write().unwrap() =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
auth_url,
|
||||
shutdown_flag: Some(child.cancel_handle()),
|
||||
});
|
||||
}
|
||||
request_frame.schedule_frame();
|
||||
let r = child.block_until_done().await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
|
||||
request_frame.schedule_frame();
|
||||
}
|
||||
_ => {
|
||||
*sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
// self.error = Some(e.to_string());
|
||||
request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
});
|
||||
self.sign_in_state =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
auth_url,
|
||||
shutdown_handle: Some(shutdown_handle),
|
||||
_login_wait_handle: Some(join_handle),
|
||||
});
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
Err(e) => {
|
||||
self.sign_in_state = SignInState::PickMode;
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
self.error = Some(e.to_string());
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,33 +375,18 @@ impl AuthModeWidget {
|
||||
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) {
|
||||
// We already have an API key configured (e.g., from auth.json or env),
|
||||
// so mark this step complete immediately.
|
||||
self.sign_in_state = SignInState::EnvVarFound;
|
||||
*self.sign_in_state.write().unwrap() = SignInState::EnvVarFound;
|
||||
} else {
|
||||
self.sign_in_state = SignInState::EnvVarMissing;
|
||||
*self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing;
|
||||
}
|
||||
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_completion_poller(
|
||||
child: codex_login::LoginServer,
|
||||
event_tx: AppEventSender,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(()) = child.block_until_done().await {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
|
||||
} else {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Err(
|
||||
"login failed".to_string()
|
||||
)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl StepStateProvider for AuthModeWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match &self.sign_in_state {
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::PickMode
|
||||
| SignInState::EnvVarMissing
|
||||
| SignInState::ChatGptContinueInBrowser(_)
|
||||
@@ -391,7 +398,8 @@ impl StepStateProvider for AuthModeWidget {
|
||||
|
||||
impl WidgetRef for AuthModeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self.sign_in_state {
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::PickMode => {
|
||||
self.render_pick_mode(area, buf);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// This doesn't render anything explicitly but serves as a signal that we made it to the end and
|
||||
/// we should continue to the chat.
|
||||
pub(crate) struct ContinueToChatWidget {
|
||||
pub event_tx: AppEventSender,
|
||||
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
|
||||
}
|
||||
|
||||
impl StepStateProvider for ContinueToChatWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
StepState::Complete
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ContinueToChatWidget {
|
||||
fn render_ref(&self, _area: Rect, _buf: &mut Buffer) {
|
||||
if let Ok(args) = self.chat_widget_args.lock() {
|
||||
self.event_tx
|
||||
.send(AppEvent::OnboardingComplete(args.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
mod auth;
|
||||
mod continue_to_chat;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub use trust_directory::TrustDirectorySelection;
|
||||
mod welcome;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
@@ -9,25 +11,24 @@ use ratatui::widgets::WidgetRef;
|
||||
use codex_login::AuthMode;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
use crate::onboarding::continue_to_chat::ContinueToChatWidget;
|
||||
use crate::onboarding::trust_directory::TrustDirectorySelection;
|
||||
use crate::onboarding::trust_directory::TrustDirectoryWidget;
|
||||
use crate::onboarding::welcome::WelcomeWidget;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Step {
|
||||
Welcome(WelcomeWidget),
|
||||
Auth(AuthModeWidget),
|
||||
TrustDirectory(TrustDirectoryWidget),
|
||||
ContinueToChat(ContinueToChatWidget),
|
||||
}
|
||||
|
||||
pub(crate) trait KeyboardHandler {
|
||||
@@ -45,43 +46,42 @@ pub(crate) trait StepStateProvider {
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreen {
|
||||
event_tx: AppEventSender,
|
||||
request_frame: FrameRequester,
|
||||
steps: Vec<Step>,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreenArgs {
|
||||
pub event_tx: AppEventSender,
|
||||
pub chat_widget_args: ChatWidgetArgs,
|
||||
pub codex_home: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub show_trust_screen: bool,
|
||||
pub show_login_screen: bool,
|
||||
pub login_status: LoginStatus,
|
||||
pub preferred_auth_method: AuthMode,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(args: OnboardingScreenArgs) -> Self {
|
||||
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
||||
let OnboardingScreenArgs {
|
||||
event_tx,
|
||||
chat_widget_args,
|
||||
codex_home,
|
||||
cwd,
|
||||
show_trust_screen,
|
||||
show_login_screen,
|
||||
login_status,
|
||||
preferred_auth_method,
|
||||
} = args;
|
||||
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
|
||||
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
|
||||
})];
|
||||
if show_login_screen {
|
||||
steps.push(Step::Auth(AuthModeWidget {
|
||||
event_tx: event_tx.clone(),
|
||||
request_frame: tui.frame_requester(),
|
||||
highlighted_mode: AuthMode::ChatGPT,
|
||||
error: None,
|
||||
sign_in_state: SignInState::PickMode,
|
||||
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
|
||||
codex_home: codex_home.clone(),
|
||||
login_status,
|
||||
preferred_auth_method: chat_widget_args.config.preferred_auth_method,
|
||||
preferred_auth_method,
|
||||
}))
|
||||
}
|
||||
let is_git_repo = is_inside_git_repo(&cwd);
|
||||
@@ -91,9 +91,6 @@ impl OnboardingScreen {
|
||||
// Default to not trusting the directory if it's not a git repo.
|
||||
TrustDirectorySelection::DontTrust
|
||||
};
|
||||
// Share ChatWidgetArgs between steps so changes in the TrustDirectory step
|
||||
// are reflected when continuing to chat.
|
||||
let shared_chat_args = Arc::new(Mutex::new(chat_widget_args));
|
||||
if show_trust_screen {
|
||||
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
|
||||
cwd,
|
||||
@@ -102,39 +99,13 @@ impl OnboardingScreen {
|
||||
selection: None,
|
||||
highlighted,
|
||||
error: None,
|
||||
chat_widget_args: shared_chat_args.clone(),
|
||||
}))
|
||||
}
|
||||
steps.push(Step::ContinueToChat(ContinueToChatWidget {
|
||||
event_tx: event_tx.clone(),
|
||||
chat_widget_args: shared_chat_args,
|
||||
}));
|
||||
// TODO: add git warning.
|
||||
Self { event_tx, steps }
|
||||
}
|
||||
|
||||
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) {
|
||||
let current_step = self.current_step_mut();
|
||||
if let Some(Step::Auth(state)) = current_step {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
state.sign_in_state = SignInState::ChatGptSuccessMessage;
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
let tx1 = self.event_tx.clone();
|
||||
let tx2 = self.event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
tx1.send(AppEvent::RequestRedraw);
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tx2.send(AppEvent::RequestRedraw);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
state.sign_in_state = SignInState::PickMode;
|
||||
state.error = Some(e);
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
request_frame: tui.frame_requester(),
|
||||
steps,
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,19 +139,57 @@ impl OnboardingScreen {
|
||||
out
|
||||
}
|
||||
|
||||
fn current_step_mut(&mut self) -> Option<&mut Step> {
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
|| !self
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| matches!(step.get_step_state(), StepState::InProgress))
|
||||
}
|
||||
|
||||
pub fn directory_trust_decision(&self) -> Option<TrustDirectorySelection> {
|
||||
self.steps
|
||||
.iter_mut()
|
||||
.find(|step| matches!(step.get_step_state(), StepState::InProgress))
|
||||
.iter()
|
||||
.find_map(|step| {
|
||||
if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step {
|
||||
Some(*selection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.is_done = true;
|
||||
}
|
||||
_ => {
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
};
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +255,7 @@ impl WidgetRef for &OnboardingScreen {
|
||||
impl KeyboardHandler for Step {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match self {
|
||||
Step::Welcome(_) | Step::ContinueToChat(_) => (),
|
||||
Step::Welcome(_) => (),
|
||||
Step::Auth(widget) => widget.handle_key_event(key_event),
|
||||
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
|
||||
}
|
||||
@@ -259,7 +268,6 @@ impl StepStateProvider for Step {
|
||||
Step::Welcome(w) => w.get_step_state(),
|
||||
Step::Auth(w) => w.get_step_state(),
|
||||
Step::TrustDirectory(w) => w.get_step_state(),
|
||||
Step::ContinueToChat(w) => w.get_step_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,9 +284,39 @@ impl WidgetRef for Step {
|
||||
Step::TrustDirectory(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::ContinueToChat(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_onboarding_app(
|
||||
args: OnboardingScreenArgs,
|
||||
tui: &mut Tui,
|
||||
) -> Result<Option<crate::onboarding::TrustDirectorySelection>> {
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
let mut onboarding_screen = OnboardingScreen::new(tui, args);
|
||||
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
})?;
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
while !onboarding_screen.is_done() {
|
||||
if let Some(event) = tui_events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
onboarding_screen.handle_key_event(key_event);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(onboarding_screen.directory_trust_decision())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::config::set_project_trusted;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -22,9 +20,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub(crate) struct TrustDirectoryWidget {
|
||||
pub codex_home: PathBuf,
|
||||
@@ -33,11 +28,10 @@ pub(crate) struct TrustDirectoryWidget {
|
||||
pub selection: Option<TrustDirectorySelection>,
|
||||
pub highlighted: TrustDirectorySelection,
|
||||
pub error: Option<String>,
|
||||
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum TrustDirectorySelection {
|
||||
pub enum TrustDirectorySelection {
|
||||
Trust,
|
||||
DontTrust,
|
||||
}
|
||||
@@ -156,13 +150,6 @@ impl TrustDirectoryWidget {
|
||||
// self.error = Some("Failed to set project trusted".to_string());
|
||||
}
|
||||
|
||||
// Update the in-memory chat config for this session to a more permissive
|
||||
// policy suitable for a trusted workspace.
|
||||
if let Ok(mut args) = self.chat_widget_args.lock() {
|
||||
args.config.approval_policy = AskForApproval::OnRequest;
|
||||
args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
}
|
||||
|
||||
self.selection = Some(TrustDirectorySelection::Trust);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,24 +132,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
||||
AppEvent::CodexEvent(ev) => {
|
||||
write_record("to_tui", "codex_event", ev);
|
||||
}
|
||||
AppEvent::KeyEvent(k) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "key_event",
|
||||
"event": format!("{:?}", k),
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::Paste(s) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "paste",
|
||||
"text": s,
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
|
||||
AppEvent::DispatchCommand(cmd) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum SlashCommand {
|
||||
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
|
||||
// more frequently used commands should be listed first.
|
||||
Model,
|
||||
Approvals,
|
||||
New,
|
||||
Init,
|
||||
Compact,
|
||||
@@ -37,7 +38,8 @@ impl SlashCommand {
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Model => "choose a model preset (model + reasoning effort)",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
#[cfg(debug_assertions)]
|
||||
|
||||
@@ -3,7 +3,7 @@ source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"proposed patch to 1 file (+2 -0) "
|
||||
" └ README.md (+2 -0) "
|
||||
" └ README.md "
|
||||
" 1 +first line "
|
||||
" 2 +second line "
|
||||
" "
|
||||
|
||||
@@ -4,7 +4,7 @@ assertion_line: 380
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"proposed patch to 1 file (+1 -1) "
|
||||
" └ src/lib.rs → src/lib_new.rs (+1 -1) "
|
||||
" └ src/lib.rs → src/lib_new.rs "
|
||||
" 1 line one "
|
||||
" 2 -line two "
|
||||
" 2 +line two changed "
|
||||
|
||||
@@ -19,6 +19,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
// We render the live text using markdown so it visually matches the history
|
||||
// cells. Before rendering we strip any ANSI escape sequences to avoid writing
|
||||
@@ -39,10 +40,11 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
reveal_len_at_base: usize,
|
||||
start_time: Instant,
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
}
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
text: String::from("waiting for model"),
|
||||
last_target_len: 0,
|
||||
@@ -51,6 +53,7 @@ impl StatusIndicatorWidget {
|
||||
start_time: Instant::now(),
|
||||
|
||||
app_event_tx,
|
||||
frame_requester,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +146,8 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
// Schedule next animation frame.
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
|
||||
self.frame_requester
|
||||
.schedule_frame_in(Duration::from_millis(100));
|
||||
let idx = self.current_frame();
|
||||
let elapsed = self.start_time.elapsed().as_secs();
|
||||
let shown_now = self.current_shown_len(idx);
|
||||
@@ -213,13 +216,13 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn renders_without_left_border_or_padding() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
|
||||
w.restart_with_text("Hello".to_string());
|
||||
|
||||
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
|
||||
@@ -235,9 +238,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn working_header_is_present_on_last_line() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
|
||||
w.restart_with_text("Hi".to_string());
|
||||
// Ensure some frames elapse so we get a stable state.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
@@ -256,9 +259,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn header_starts_at_expected_position() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy());
|
||||
w.restart_with_text("Hello".to_string());
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::event::PushKeyboardEnhancementFlags;
|
||||
use crossterm::terminal::Clear;
|
||||
use crossterm::terminal::ClearType;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::emoji_width;
|
||||
use tokio::select;
|
||||
use tokio_stream::Stream;
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init(_config: &Config) -> Result<Tui> {
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
@@ -40,13 +52,35 @@ pub fn init(_config: &Config) -> Result<Tui> {
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore the terminal to its original state.
|
||||
/// Inverse of `set_modes`.
|
||||
pub fn restore() -> Result<()> {
|
||||
// Pop may fail on platforms that didn't support the push; ignore errors.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
disable_raw_mode()?;
|
||||
let _ = execute!(stdout(), crossterm::cursor::Show);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init() -> Result<Terminal> {
|
||||
set_modes()?;
|
||||
|
||||
set_panic_hook();
|
||||
|
||||
// Clear screen and move cursor to top-left before drawing UI
|
||||
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
|
||||
|
||||
// Proactively probe emoji widths so downstream rendering can pick icons
|
||||
// safely without doing terminal mutations mid-render.
|
||||
emoji_width::ensure_probed();
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let tui = Terminal::with_options(backend)?;
|
||||
let tui = CustomTerminal::with_options(backend)?;
|
||||
Ok(tui)
|
||||
}
|
||||
|
||||
@@ -58,11 +92,223 @@ fn set_panic_hook() {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Restore the terminal to its original state
|
||||
pub fn restore() -> Result<()> {
|
||||
// Pop may fail on platforms that didn't support the push; ignore errors.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
disable_raw_mode()?;
|
||||
Ok(())
|
||||
#[derive(Debug)]
|
||||
pub enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
Paste(String),
|
||||
Draw,
|
||||
#[cfg(unix)]
|
||||
ResumeFromSuspend,
|
||||
}
|
||||
|
||||
pub struct Tui {
|
||||
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
|
||||
draw_tx: tokio::sync::broadcast::Sender<()>,
|
||||
pub(crate) terminal: Terminal,
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FrameRequester {
|
||||
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
|
||||
}
|
||||
impl FrameRequester {
|
||||
pub fn schedule_frame(&self) {
|
||||
let _ = self.frame_schedule_tx.send(Instant::now());
|
||||
}
|
||||
pub fn schedule_frame_in(&self, dur: Duration) {
|
||||
let _ = self.frame_schedule_tx.send(Instant::now() + dur);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FrameRequester {
|
||||
/// Create a no-op frame requester for tests.
|
||||
pub(crate) fn test_dummy() -> Self {
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
FrameRequester {
|
||||
frame_schedule_tx: tx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (draw_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
|
||||
// Spawn background scheduler to coalesce frame requests and emit draws at deadlines.
|
||||
let draw_tx_clone = draw_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::select;
|
||||
use tokio::time::Instant as TokioInstant;
|
||||
use tokio::time::sleep_until;
|
||||
|
||||
let mut rx = frame_schedule_rx;
|
||||
let mut next_deadline: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
let target = next_deadline
|
||||
.unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365));
|
||||
let sleep_fut = sleep_until(TokioInstant::from_std(target));
|
||||
tokio::pin!(sleep_fut);
|
||||
|
||||
select! {
|
||||
recv = rx.recv() => {
|
||||
match recv {
|
||||
Some(at) => {
|
||||
if next_deadline.is_none_or(|cur| at < cur) {
|
||||
next_deadline = Some(at);
|
||||
}
|
||||
if at <= Instant::now() {
|
||||
next_deadline = None;
|
||||
let _ = draw_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = &mut sleep_fut => {
|
||||
if next_deadline.is_some() {
|
||||
next_deadline = None;
|
||||
let _ = draw_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
frame_schedule_tx,
|
||||
draw_tx,
|
||||
terminal,
|
||||
pending_history_lines: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_requester(&self) -> FrameRequester {
|
||||
FrameRequester {
|
||||
frame_schedule_tx: self.frame_schedule_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
||||
use tokio_stream::StreamExt;
|
||||
let mut crossterm_events = crossterm::event::EventStream::new();
|
||||
let mut draw_rx = self.draw_tx.subscribe();
|
||||
let event_stream = async_stream::stream! {
|
||||
loop {
|
||||
select! {
|
||||
Some(Ok(event)) = crossterm_events.next() => {
|
||||
match event {
|
||||
crossterm::event::Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('z'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = Tui::suspend();
|
||||
yield TuiEvent::ResumeFromSuspend;
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
}
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
yield TuiEvent::Key(key_event);
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
crossterm::event::Event::Paste(pasted) => {
|
||||
yield TuiEvent::Paste(pasted);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result = draw_rx.recv() => {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
// We dropped one or more draw notifications; coalesce to a single draw.
|
||||
yield TuiEvent::Draw;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Sender dropped; stop emitting draws from this source.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Box::pin(event_stream)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn suspend() -> Result<()> {
|
||||
restore()?;
|
||||
unsafe { libc::kill(0, libc::SIGTSTP) };
|
||||
set_modes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
height: u16,
|
||||
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
||||
) -> Result<()> {
|
||||
std::io::stdout().sync_update(|_| {
|
||||
let terminal = &mut self.terminal;
|
||||
let screen_size = terminal.size()?;
|
||||
let last_known_screen_size = terminal.last_known_screen_size;
|
||||
if screen_size != last_known_screen_size {
|
||||
let cursor_pos = terminal.get_cursor_position()?;
|
||||
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
||||
if cursor_pos.y != last_known_cursor_pos.y {
|
||||
let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32;
|
||||
|
||||
let new_viewport_area = terminal.viewport_area.offset(Offset {
|
||||
x: 0,
|
||||
y: cursor_delta,
|
||||
});
|
||||
terminal.set_viewport_area(new_viewport_area);
|
||||
terminal.clear()?;
|
||||
}
|
||||
}
|
||||
|
||||
let size = terminal.size()?;
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
area.height = height.min(size.height);
|
||||
area.width = size.width;
|
||||
if area.bottom() > size.height {
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
|
||||
area.y = size.height - area.height;
|
||||
}
|
||||
if area != terminal.viewport_area {
|
||||
terminal.clear()?;
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
if !self.pending_history_lines.is_empty() {
|
||||
crate::insert_history::insert_history_lines(
|
||||
terminal,
|
||||
self.pending_history_lines.clone(),
|
||||
);
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
terminal.draw(|frame| {
|
||||
draw_fn(frame);
|
||||
})?;
|
||||
Ok(())
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,11 @@ static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
pub(crate) struct UserApprovalWidget {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
select_options: &'a Vec<SelectOption>,
|
||||
confirmation_prompt: Paragraph<'static>,
|
||||
select_options: &'static Vec<SelectOption>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
selected_option: usize,
|
||||
@@ -137,7 +137,7 @@ fn to_command_display<'a>(
|
||||
lines
|
||||
}
|
||||
|
||||
impl UserApprovalWidget<'_> {
|
||||
impl UserApprovalWidget {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let confirmation_prompt = match &approval_request {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -356,7 +356,7 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
impl WidgetRef for &UserApprovalWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||||
let [prompt_chunk, response_chunk] = Layout::default()
|
||||
@@ -424,11 +424,11 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use std::sync::mpsc::channel;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn lowercase_shortcut_is_accepted() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
@@ -438,7 +438,10 @@ mod tests {
|
||||
let mut widget = UserApprovalWidget::new(req, tx);
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
assert!(widget.is_complete());
|
||||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||||
let mut events: Vec<AppEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
AppEvent::CodexOp(Op::ExecApproval {
|
||||
@@ -450,7 +453,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn uppercase_shortcut_is_accepted() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "2".to_string(),
|
||||
@@ -460,7 +463,10 @@ mod tests {
|
||||
let mut widget = UserApprovalWidget::new(req, tx);
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
|
||||
assert!(widget.is_complete());
|
||||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||||
let mut events: Vec<AppEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
AppEvent::CodexOp(Op::ExecApproval {
|
||||
|
||||
Reference in New Issue
Block a user