mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
Compare commits
10 Commits
etraut/exe
...
fcoury/vim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e98e93e277 | ||
|
|
ac5067f921 | ||
|
|
fd0e287d96 | ||
|
|
5f86c5259b | ||
|
|
11bf378594 | ||
|
|
727b57e73e | ||
|
|
26b9335406 | ||
|
|
5f69d89d94 | ||
|
|
4e3f68330a | ||
|
|
9f42c89c01 |
7
codex-rs/Cargo.lock
generated
7
codex-rs/Cargo.lock
generated
@@ -2236,6 +2236,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-install-context",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
@@ -2260,7 +2261,9 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"crossterm",
|
||||
"http 1.4.0",
|
||||
"insta",
|
||||
"libc",
|
||||
"os_info",
|
||||
"owo-colors",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
@@ -2269,12 +2272,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"supports-color 3.0.2",
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"unicode-segmentation",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -37,6 +37,7 @@ codex-exec = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-memories-write = { workspace = true }
|
||||
@@ -59,11 +60,13 @@ codex-utils-path = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
http = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
@@ -78,14 +81,21 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
which = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
insta = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
|
||||
@@ -66,12 +66,16 @@ use serde::Serialize;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod background;
|
||||
mod git;
|
||||
mod output;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod system;
|
||||
mod title;
|
||||
mod updates;
|
||||
|
||||
use background::background_server_check;
|
||||
use git::git_check;
|
||||
use output::HumanOutputOptions;
|
||||
use output::redact_detail;
|
||||
use output::render_human_report;
|
||||
@@ -79,6 +83,8 @@ use progress::DoctorProgress;
|
||||
use progress::doctor_progress;
|
||||
use runtime::runtime_check;
|
||||
use runtime::search_check;
|
||||
use system::system_check;
|
||||
use title::terminal_title_check;
|
||||
use updates::updates_check;
|
||||
|
||||
const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
|
||||
@@ -330,6 +336,7 @@ async fn build_report(
|
||||
) -> DoctorReport {
|
||||
let progress = doctor_progress(command.json);
|
||||
let mut checks = Vec::new();
|
||||
checks.push(run_sync_check("system", progress.clone(), system_check));
|
||||
checks.push(run_sync_check("installation", progress.clone(), || {
|
||||
installation_check(!command.summary)
|
||||
}));
|
||||
@@ -352,6 +359,8 @@ async fn build_report(
|
||||
mcp_check,
|
||||
sandbox_check,
|
||||
terminal_check,
|
||||
git_check,
|
||||
terminal_title_check,
|
||||
state_check,
|
||||
background_server_check,
|
||||
reachability_check,
|
||||
@@ -376,6 +385,12 @@ async fn build_report(
|
||||
terminal_check(command.no_color)
|
||||
})
|
||||
},
|
||||
run_async_check("git", progress.clone(), git_check(config.cwd.as_path())),
|
||||
async {
|
||||
run_sync_check("terminal title", progress.clone(), || {
|
||||
terminal_title_check(config)
|
||||
})
|
||||
},
|
||||
run_async_check("state", progress.clone(), state_check(config)),
|
||||
async {
|
||||
run_sync_check("app-server", progress.clone(), || {
|
||||
@@ -397,6 +412,8 @@ async fn build_report(
|
||||
mcp_check,
|
||||
sandbox_check,
|
||||
terminal_check,
|
||||
git_check,
|
||||
terminal_title_check,
|
||||
state_check,
|
||||
background_server_check,
|
||||
reachability_check,
|
||||
@@ -404,7 +421,18 @@ async fn build_report(
|
||||
}
|
||||
Err(err) => {
|
||||
let reachability_plan = default_reachability_plan();
|
||||
let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!(
|
||||
let fallback_cwd = interactive
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let (
|
||||
config_check,
|
||||
network_check,
|
||||
terminal_check,
|
||||
git_check,
|
||||
state_check,
|
||||
reachability_check,
|
||||
) = tokio::join!(
|
||||
async {
|
||||
run_sync_check("config", progress.clone(), || {
|
||||
DoctorCheck::new(
|
||||
@@ -423,6 +451,7 @@ async fn build_report(
|
||||
terminal_check(command.no_color)
|
||||
})
|
||||
},
|
||||
run_async_check("git", progress.clone(), git_check(fallback_cwd.as_path())),
|
||||
async { run_sync_check("state", progress.clone(), fallback_state_check) },
|
||||
run_async_check(
|
||||
"provider reachability",
|
||||
@@ -434,6 +463,7 @@ async fn build_report(
|
||||
config_check,
|
||||
network_check,
|
||||
terminal_check,
|
||||
git_check,
|
||||
state_check,
|
||||
reachability_check,
|
||||
]);
|
||||
@@ -1034,6 +1064,7 @@ fn config_check(config: &Config) -> DoctorCheck {
|
||||
let status = if config.startup_warnings.is_empty() {
|
||||
CheckStatus::Ok
|
||||
} else {
|
||||
push_startup_warning_counts(&mut details, &config.startup_warnings);
|
||||
details.extend(
|
||||
config
|
||||
.startup_warnings
|
||||
@@ -1046,6 +1077,23 @@ fn config_check(config: &Config) -> DoctorCheck {
|
||||
DoctorCheck::new("config.load", "config", status, "config loaded").details(details)
|
||||
}
|
||||
|
||||
fn push_startup_warning_counts(details: &mut Vec<String>, warnings: &[String]) {
|
||||
details.push(format!("startup warnings: {}", warnings.len()));
|
||||
for (label, needle) in [
|
||||
("startup warning skills", "skill"),
|
||||
("startup warning hooks", "hook"),
|
||||
("startup warning plugins", "plugin"),
|
||||
("startup warning MCP", "mcp"),
|
||||
("startup warning deprecated", "deprecated"),
|
||||
] {
|
||||
let count = warnings
|
||||
.iter()
|
||||
.filter(|warning| warning.to_ascii_lowercase().contains(needle))
|
||||
.count();
|
||||
details.push(format!("{label}: {count}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn feature_flag_details(config: &Config, details: &mut Vec<String>) {
|
||||
let features = config.features.get();
|
||||
let enabled_features = FEATURES
|
||||
@@ -1579,6 +1627,7 @@ struct TerminalCheckInputs {
|
||||
stream_supports_color: bool,
|
||||
terminal_size: Result<(u16, u16), String>,
|
||||
tmux_details: Vec<String>,
|
||||
windows_console_details: Vec<String>,
|
||||
}
|
||||
|
||||
impl TerminalCheckInputs {
|
||||
@@ -1592,6 +1641,7 @@ impl TerminalCheckInputs {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let windows_console_details = windows_console_details();
|
||||
Self {
|
||||
info,
|
||||
env,
|
||||
@@ -1603,6 +1653,7 @@ impl TerminalCheckInputs {
|
||||
stream_supports_color: supports_color::on(Stream::Stdout).is_some(),
|
||||
terminal_size,
|
||||
tmux_details,
|
||||
windows_console_details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1619,6 +1670,51 @@ fn terminal_check(no_color_flag: bool) -> DoctorCheck {
|
||||
terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_console_details() -> Vec<String> {
|
||||
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
|
||||
use windows_sys::Win32::System::Console::GetConsoleCP;
|
||||
use windows_sys::Win32::System::Console::GetConsoleMode;
|
||||
use windows_sys::Win32::System::Console::GetConsoleOutputCP;
|
||||
use windows_sys::Win32::System::Console::GetStdHandle;
|
||||
use windows_sys::Win32::System::Console::STD_ERROR_HANDLE;
|
||||
use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE;
|
||||
|
||||
let mut details = Vec::new();
|
||||
details.push(format!("console input code page: {}", unsafe {
|
||||
GetConsoleCP()
|
||||
}));
|
||||
details.push(format!("console output code page: {}", unsafe {
|
||||
GetConsoleOutputCP()
|
||||
}));
|
||||
details.push(console_mode_detail("stdout console mode", unsafe {
|
||||
GetStdHandle(STD_OUTPUT_HANDLE)
|
||||
}));
|
||||
details.push(console_mode_detail("stderr console mode", unsafe {
|
||||
GetStdHandle(STD_ERROR_HANDLE)
|
||||
}));
|
||||
|
||||
fn console_mode_detail(label: &str, handle: isize) -> String {
|
||||
if handle == 0 || handle == INVALID_HANDLE_VALUE {
|
||||
return format!("{label}: unavailable");
|
||||
}
|
||||
let mut mode = 0_u32;
|
||||
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
|
||||
return format!("{label}: unavailable");
|
||||
}
|
||||
let vt_enabled = mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0;
|
||||
format!("{label}: 0x{mode:08x} (VT processing: {vt_enabled})")
|
||||
}
|
||||
|
||||
details
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_console_details() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck {
|
||||
let info = &inputs.info;
|
||||
let name = info.name;
|
||||
@@ -1652,6 +1748,7 @@ fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck {
|
||||
}
|
||||
push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS);
|
||||
details.extend(inputs.tmux_details.iter().cloned());
|
||||
details.extend(inputs.windows_console_details.iter().cloned());
|
||||
|
||||
let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale);
|
||||
let mut issues = Vec::new();
|
||||
@@ -3030,6 +3127,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_warning_counts_group_known_sources() {
|
||||
let warnings = vec![
|
||||
"Skipped loading 2 skill(s) due to invalid SKILL.md files.".to_string(),
|
||||
"[features].codex_hooks is deprecated. Use [features].hooks instead.".to_string(),
|
||||
"plugin example failed to load".to_string(),
|
||||
"MCP server example failed to start".to_string(),
|
||||
];
|
||||
let mut details = Vec::new();
|
||||
|
||||
push_startup_warning_counts(&mut details, &warnings);
|
||||
|
||||
assert_eq!(
|
||||
details,
|
||||
vec![
|
||||
"startup warnings: 4",
|
||||
"startup warning skills: 1",
|
||||
"startup warning hooks: 1",
|
||||
"startup warning plugins: 1",
|
||||
"startup warning MCP: 1",
|
||||
"startup warning deprecated: 1",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_overrides_from_interactive_preserves_global_options() {
|
||||
let interactive = TuiCli::parse_from([
|
||||
@@ -3723,6 +3845,7 @@ mod tests {
|
||||
stream_supports_color: true,
|
||||
terminal_size: Ok((120, 40)),
|
||||
tmux_details: Vec::new(),
|
||||
windows_console_details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3856,6 +3979,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_check_includes_windows_console_details() {
|
||||
let mut inputs = terminal_inputs();
|
||||
inputs
|
||||
.windows_console_details
|
||||
.push("stdout console mode: 0x00000004 (VT processing: true)".to_string());
|
||||
|
||||
let check = terminal_check_from_inputs(inputs);
|
||||
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"stdout console mode: 0x00000004 (VT processing: true)".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_check_keeps_tmux_probe_failures_non_fatal() {
|
||||
let mut inputs = terminal_inputs();
|
||||
|
||||
378
codex-rs/cli/src/doctor/git.rs
Normal file
378
codex-rs/cli/src/doctor/git.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
use super::DoctorIssue;
|
||||
|
||||
const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
struct GitCheckInputs {
|
||||
selected_git: Option<PathBuf>,
|
||||
git_candidates: Vec<PathBuf>,
|
||||
git_version: Option<String>,
|
||||
git_exec_path: Option<String>,
|
||||
git_build_options: Option<String>,
|
||||
repo_root: Option<PathBuf>,
|
||||
git_entry: Option<String>,
|
||||
branch: Option<String>,
|
||||
core_fsmonitor: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn git_check(cwd: &Path) -> DoctorCheck {
|
||||
let selected_git = which::which("git").ok();
|
||||
let git_candidates = git_candidates();
|
||||
let repo_root = get_git_repo_root(cwd);
|
||||
|
||||
let (git_version, git_exec_path, git_build_options, branch, core_fsmonitor) =
|
||||
if let Some(git_path) = selected_git.as_deref() {
|
||||
let (version, exec_path, build_options, branch, fsmonitor) = tokio::join!(
|
||||
git_output(git_path, cwd, &["--version"]),
|
||||
git_output(git_path, cwd, &["--exec-path"]),
|
||||
git_output(git_path, cwd, &["version", "--build-options"]),
|
||||
git_output(git_path, cwd, &["rev-parse", "--abbrev-ref", "HEAD"]),
|
||||
git_output(git_path, cwd, &["config", "--get", "core.fsmonitor"]),
|
||||
);
|
||||
(version, exec_path, build_options, branch, fsmonitor)
|
||||
} else {
|
||||
(None, None, None, None, None)
|
||||
};
|
||||
|
||||
git_check_from_inputs(GitCheckInputs {
|
||||
selected_git,
|
||||
git_candidates,
|
||||
git_version,
|
||||
git_exec_path,
|
||||
git_build_options,
|
||||
git_entry: repo_root.as_deref().map(git_entry_summary),
|
||||
repo_root,
|
||||
branch,
|
||||
core_fsmonitor,
|
||||
})
|
||||
}
|
||||
|
||||
fn git_check_from_inputs(inputs: GitCheckInputs) -> DoctorCheck {
|
||||
let mut details = Vec::new();
|
||||
match inputs.selected_git.as_deref() {
|
||||
Some(path) => details.push(format!("selected git: {}", path.display())),
|
||||
None => details.push("selected git: not found".to_string()),
|
||||
}
|
||||
details.push(format!("PATH git entries: {}", inputs.git_candidates.len()));
|
||||
for (index, path) in inputs.git_candidates.iter().enumerate() {
|
||||
details.push(format!("PATH git #{}: {}", index + 1, path.display()));
|
||||
}
|
||||
push_optional_detail(&mut details, "git version", inputs.git_version.as_deref());
|
||||
push_optional_detail(
|
||||
&mut details,
|
||||
"git exec path",
|
||||
inputs.git_exec_path.as_deref(),
|
||||
);
|
||||
push_optional_detail(
|
||||
&mut details,
|
||||
"git build options",
|
||||
inputs.git_build_options.as_deref(),
|
||||
);
|
||||
match inputs.repo_root.as_deref() {
|
||||
Some(root) => {
|
||||
details.push("repo detected: true".to_string());
|
||||
details.push(format!("repo root: {}", root.display()));
|
||||
}
|
||||
None => details.push("repo detected: false".to_string()),
|
||||
}
|
||||
push_optional_detail(&mut details, ".git entry", inputs.git_entry.as_deref());
|
||||
push_optional_detail(
|
||||
&mut details,
|
||||
"git branch",
|
||||
normalized_branch(inputs.branch.as_deref()),
|
||||
);
|
||||
push_optional_detail(
|
||||
&mut details,
|
||||
"core.fsmonitor",
|
||||
inputs
|
||||
.core_fsmonitor
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty()),
|
||||
);
|
||||
|
||||
let mut check = DoctorCheck::new(
|
||||
"git.environment",
|
||||
"git",
|
||||
CheckStatus::Ok,
|
||||
git_summary(&inputs),
|
||||
)
|
||||
.details(details);
|
||||
|
||||
if inputs.selected_git.is_some() && inputs.git_version.is_none() {
|
||||
check.status = CheckStatus::Warning;
|
||||
check.summary = "Git executable found but could not be run".to_string();
|
||||
check = check.issue(
|
||||
DoctorIssue::new(
|
||||
CheckStatus::Warning,
|
||||
"Git executable was found on PATH but did not return a version",
|
||||
)
|
||||
.expected("git --version succeeds")
|
||||
.remedy("Fix the selected Git executable or PATH so Codex can inspect Git metadata.")
|
||||
.field("git version")
|
||||
.field("selected git"),
|
||||
);
|
||||
} else if inputs.selected_git.is_none() && inputs.repo_root.is_some() {
|
||||
check.status = CheckStatus::Warning;
|
||||
check.summary = "Git repository detected but git executable was not found".to_string();
|
||||
check = check.issue(
|
||||
DoctorIssue::new(
|
||||
CheckStatus::Warning,
|
||||
"Git repository detected but git executable was not found",
|
||||
)
|
||||
.expected("git available on PATH")
|
||||
.remedy("Install Git or fix PATH so Codex can inspect repository metadata.")
|
||||
.field("selected git"),
|
||||
);
|
||||
} else if let Some(cause) =
|
||||
old_windows_git_warning(inputs.git_version.as_deref(), cfg!(windows))
|
||||
{
|
||||
check.status = CheckStatus::Warning;
|
||||
check.summary = cause.clone();
|
||||
check = check.issue(
|
||||
DoctorIssue::new(CheckStatus::Warning, cause)
|
||||
.measured(inputs.git_version.unwrap_or_else(|| "unknown".to_string()))
|
||||
.expected("current Git for Windows")
|
||||
.remedy(
|
||||
"Update Git for Windows or the bundled Git executable Codex resolves first.",
|
||||
)
|
||||
.field("git version")
|
||||
.field("selected git"),
|
||||
);
|
||||
}
|
||||
|
||||
check
|
||||
}
|
||||
|
||||
fn git_summary(inputs: &GitCheckInputs) -> String {
|
||||
match inputs.git_version.as_deref() {
|
||||
Some(version) => version.to_string(),
|
||||
None if inputs.selected_git.is_some() => {
|
||||
"git executable found; version unavailable".to_string()
|
||||
}
|
||||
None => "git executable not found".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_optional_detail(details: &mut Vec<String>, label: &str, value: Option<&str>) {
|
||||
if let Some(value) = value {
|
||||
details.push(format!("{label}: {value}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_branch(branch: Option<&str>) -> Option<&str> {
|
||||
match branch {
|
||||
Some("HEAD") => Some("detached HEAD"),
|
||||
Some(value) if !value.is_empty() => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn git_candidates() -> Vec<PathBuf> {
|
||||
let Ok(candidates) = which::which_all("git") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
candidates
|
||||
.filter(|candidate| seen.insert(candidate.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn git_output(git_path: &Path, cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let mut command = Command::new(git_path);
|
||||
command
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.kill_on_drop(true);
|
||||
let output = timeout(GIT_COMMAND_TIMEOUT, command.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
command_output_text(output)
|
||||
}
|
||||
|
||||
fn command_output_text(output: Output) -> Option<String> {
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let normalized = stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn git_entry_summary(repo_root: &Path) -> String {
|
||||
let entry = repo_root.join(".git");
|
||||
match std::fs::metadata(&entry) {
|
||||
Ok(metadata) if metadata.is_dir() => "directory".to_string(),
|
||||
Ok(metadata) if metadata.is_file() => std::fs::read_to_string(&entry)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
contents
|
||||
.strip_prefix("gitdir:")
|
||||
.map(str::trim)
|
||||
.map(|path| format!("file -> {path}"))
|
||||
})
|
||||
.unwrap_or_else(|| "file".to_string()),
|
||||
Ok(_) => "other".to_string(),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => "missing".to_string(),
|
||||
Err(err) => format!("unreadable ({err})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn old_windows_git_warning(version: Option<&str>, is_windows: bool) -> Option<String> {
|
||||
if !is_windows {
|
||||
return None;
|
||||
}
|
||||
let version = version?;
|
||||
if version.to_ascii_lowercase().contains("msysgit") {
|
||||
return Some("old msysgit installation may corrupt Windows TUI rendering".to_string());
|
||||
}
|
||||
let parsed = parse_git_version(version)?;
|
||||
if parsed.major < 2 || (parsed.major == 2 && parsed.minor <= 34) {
|
||||
return Some("old Git for Windows may corrupt Windows TUI rendering".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct ParsedGitVersion {
|
||||
major: u32,
|
||||
minor: u32,
|
||||
patch: u32,
|
||||
}
|
||||
|
||||
fn parse_git_version(version: &str) -> Option<ParsedGitVersion> {
|
||||
let version = version.strip_prefix("git version ")?;
|
||||
let numeric = version
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.split(".windows.")
|
||||
.next()
|
||||
.unwrap_or(version);
|
||||
let mut parts = numeric.split('.');
|
||||
Some(ParsedGitVersion {
|
||||
major: parts.next()?.parse().ok()?,
|
||||
minor: parts.next()?.parse().ok()?,
|
||||
patch: parts.next().unwrap_or("0").parse().ok()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_git_for_windows_version() {
|
||||
assert_eq!(
|
||||
parse_git_version("git version 2.34.1.windows.1"),
|
||||
Some(ParsedGitVersion {
|
||||
major: 2,
|
||||
minor: 34,
|
||||
patch: 1,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_git_version("git version 2.54.0.windows.1"),
|
||||
Some(ParsedGitVersion {
|
||||
major: 2,
|
||||
minor: 54,
|
||||
patch: 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_old_windows_git() {
|
||||
assert_eq!(
|
||||
old_windows_git_warning(
|
||||
Some("git version 2.34.1.windows.1"),
|
||||
/*is_windows*/ true
|
||||
)
|
||||
.as_deref(),
|
||||
Some("old Git for Windows may corrupt Windows TUI rendering")
|
||||
);
|
||||
assert_eq!(
|
||||
old_windows_git_warning(
|
||||
Some("git version 2.54.0.windows.1"),
|
||||
/*is_windows*/ true
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
old_windows_git_warning(
|
||||
Some("git version 2.34.1.windows.1"),
|
||||
/*is_windows*/ false
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_when_git_repo_has_no_git_executable() {
|
||||
let check = git_check_from_inputs(GitCheckInputs {
|
||||
repo_root: Some(PathBuf::from("/repo")),
|
||||
..GitCheckInputs::default()
|
||||
});
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Warning);
|
||||
assert_eq!(
|
||||
check.summary,
|
||||
"Git repository detected but git executable was not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_when_selected_git_cannot_report_version() {
|
||||
let check = git_check_from_inputs(GitCheckInputs {
|
||||
selected_git: Some(PathBuf::from("/usr/bin/git")),
|
||||
repo_root: Some(PathBuf::from("/repo")),
|
||||
..GitCheckInputs::default()
|
||||
});
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Warning);
|
||||
assert_eq!(check.summary, "Git executable found but could not be run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_git_candidates_and_repo_metadata() {
|
||||
let check = git_check_from_inputs(GitCheckInputs {
|
||||
selected_git: Some(PathBuf::from("/usr/bin/git")),
|
||||
git_candidates: vec![PathBuf::from("/usr/bin/git"), PathBuf::from("/opt/bin/git")],
|
||||
git_version: Some("git version 2.54.0".to_string()),
|
||||
git_exec_path: Some("/usr/libexec/git-core".to_string()),
|
||||
repo_root: Some(PathBuf::from("/repo")),
|
||||
git_entry: Some("directory".to_string()),
|
||||
branch: Some("main".to_string()),
|
||||
core_fsmonitor: Some("false".to_string()),
|
||||
..GitCheckInputs::default()
|
||||
});
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert!(check.details.contains(&"PATH git entries: 2".to_string()));
|
||||
assert!(check.details.contains(&"git branch: main".to_string()));
|
||||
assert!(check.details.contains(&"core.fsmonitor: false".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ const SEPARATOR_WIDTH: usize = 61;
|
||||
const GROUPS: &[OutputGroup] = &[
|
||||
OutputGroup {
|
||||
title: "Environment",
|
||||
keys: &["runtime", "install", "search", "terminal", "state"],
|
||||
keys: &[
|
||||
"system", "runtime", "install", "search", "git", "terminal", "title", "state",
|
||||
],
|
||||
},
|
||||
OutputGroup {
|
||||
title: "Configuration",
|
||||
@@ -611,12 +613,15 @@ fn auth_reachability_note(report: &DoctorReport) -> Option<DoctorNote> {
|
||||
None
|
||||
}
|
||||
|
||||
fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String {
|
||||
fn display_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String {
|
||||
match check.category.as_str() {
|
||||
"system" => system_summary(check),
|
||||
"runtime" => runtime_summary(check),
|
||||
"install" if check.status == CheckStatus::Ok => "consistent".to_string(),
|
||||
"search" => search_summary(check),
|
||||
"git" => git_summary(check),
|
||||
"terminal" => terminal_summary(check),
|
||||
"title" => title_summary(check, options),
|
||||
"state" => state_summary(check),
|
||||
"config" if check.status == CheckStatus::Ok => "loaded".to_string(),
|
||||
"mcp" => mcp_summary(check),
|
||||
@@ -628,6 +633,10 @@ fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String
|
||||
}
|
||||
}
|
||||
|
||||
fn system_summary(check: &DoctorCheck) -> String {
|
||||
detail::detail_value(check, "os language").unwrap_or_else(|| check.summary.clone())
|
||||
}
|
||||
|
||||
fn runtime_summary(check: &DoctorCheck) -> String {
|
||||
if detail::detail_value(check, "current executable")
|
||||
.is_some_and(|path| path.contains("/target/debug/"))
|
||||
@@ -649,6 +658,12 @@ fn search_summary(check: &DoctorCheck) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn git_summary(check: &DoctorCheck) -> String {
|
||||
detail::detail_value(check, "git version")
|
||||
.or_else(|| detail::detail_value(check, "selected git"))
|
||||
.unwrap_or_else(|| check.summary.clone())
|
||||
}
|
||||
|
||||
fn terminal_summary(check: &DoctorCheck) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(terminal) = detail::detail_value(check, "terminal") {
|
||||
@@ -668,6 +683,19 @@ fn terminal_summary(check: &DoctorCheck) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn title_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String {
|
||||
let source = detail::detail_value(check, "terminal title source");
|
||||
let project = detail::detail_value(check, "terminal title project value");
|
||||
match (source, project) {
|
||||
(Some(source), Some(project)) => {
|
||||
let separator = if options.ascii { " | " } else { " · " };
|
||||
format!("{source}{separator}project {project}")
|
||||
}
|
||||
(Some(source), None) => source,
|
||||
_ => check.summary.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_summary(check: &DoctorCheck) -> String {
|
||||
let databases_ok = [
|
||||
"state DB integrity",
|
||||
@@ -1096,6 +1124,14 @@ mod tests {
|
||||
|
||||
fn sample_report() -> DoctorReport {
|
||||
let checks = vec![
|
||||
DoctorCheck::new(
|
||||
"system.environment",
|
||||
"system",
|
||||
CheckStatus::Ok,
|
||||
"OS language en-US",
|
||||
)
|
||||
.detail("os: macOS 15.0")
|
||||
.detail("os language: en-US"),
|
||||
DoctorCheck::new(
|
||||
"runtime.provenance",
|
||||
"runtime",
|
||||
@@ -1114,12 +1150,30 @@ mod tests {
|
||||
CheckStatus::Ok,
|
||||
"search is OK (bundled)",
|
||||
),
|
||||
DoctorCheck::new(
|
||||
"git.environment",
|
||||
"git",
|
||||
CheckStatus::Ok,
|
||||
"git version 2.54.0",
|
||||
)
|
||||
.detail("selected git: /usr/bin/git")
|
||||
.detail("git version: git version 2.54.0")
|
||||
.detail("repo detected: true"),
|
||||
DoctorCheck::new(
|
||||
"terminal.env",
|
||||
"terminal",
|
||||
CheckStatus::Warning,
|
||||
"narrow terminal",
|
||||
),
|
||||
DoctorCheck::new(
|
||||
"terminal.title",
|
||||
"title",
|
||||
CheckStatus::Ok,
|
||||
"terminal title default",
|
||||
)
|
||||
.detail("terminal title source: default")
|
||||
.detail("terminal title items: activity, project-name")
|
||||
.detail("terminal title project value: codex"),
|
||||
DoctorCheck::new(
|
||||
"state.paths",
|
||||
"state",
|
||||
@@ -1187,11 +1241,22 @@ Notes
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
Environment
|
||||
✓ system en-US
|
||||
os macOS 15.0
|
||||
OS language en-US
|
||||
✓ runtime running local build on darwin-arm64
|
||||
✓ install consistent
|
||||
managed by npm: no · bun: no · package root —
|
||||
✓ search search is OK (bundled)
|
||||
✓ git git version 2.54.0
|
||||
selected git /usr/bin/git
|
||||
version git version 2.54.0
|
||||
repo detected true
|
||||
⚠ terminal narrow terminal
|
||||
✓ title default · project codex
|
||||
title source default
|
||||
title items activity, project-name
|
||||
project value codex
|
||||
✓ state state paths inspectable
|
||||
|
||||
Configuration
|
||||
@@ -1210,7 +1275,7 @@ Background Server
|
||||
✓ app-server background server is not running
|
||||
|
||||
{}
|
||||
9 ok · 2 notes · 1 warn · 1 fail failed
|
||||
12 ok · 2 notes · 1 warn · 1 fail failed
|
||||
|
||||
--summary compact output --all expand truncated lists
|
||||
--json redacted report
|
||||
@@ -1220,6 +1285,14 @@ Background Server
|
||||
assert_eq!(rendered, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_human_report_snapshot_covers_environment_rows() {
|
||||
insta::assert_snapshot!(
|
||||
"doctor_human_report_environment_rows",
|
||||
render_human_report(&sample_report(), detailed_no_color_unicode_options())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_human_report_supports_summary_output_without_color() {
|
||||
let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options());
|
||||
@@ -1233,10 +1306,13 @@ Notes
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
Environment
|
||||
✓ system en-US
|
||||
✓ runtime running local build on darwin-arm64
|
||||
✓ install consistent
|
||||
✓ search search is OK (bundled)
|
||||
✓ git git version 2.54.0
|
||||
⚠ terminal narrow terminal
|
||||
✓ title default · project codex
|
||||
✓ state state paths inspectable
|
||||
|
||||
Configuration
|
||||
@@ -1254,7 +1330,7 @@ Background Server
|
||||
✓ app-server background server is not running
|
||||
|
||||
{}
|
||||
9 ok · 2 notes · 1 warn · 1 fail failed
|
||||
12 ok · 2 notes · 1 warn · 1 fail failed
|
||||
|
||||
Run codex doctor without --summary for detailed diagnostics.
|
||||
--all expand truncated lists --json redacted report
|
||||
@@ -1285,10 +1361,13 @@ Notes
|
||||
-------------------------------------------------------------
|
||||
|
||||
Environment
|
||||
[ok] system en-US
|
||||
[ok] runtime running local build on darwin-arm64
|
||||
[ok] install consistent
|
||||
[ok] search search is OK (bundled)
|
||||
[ok] git git version 2.54.0
|
||||
[!!] terminal narrow terminal
|
||||
[ok] title default | project codex
|
||||
[ok] state state paths inspectable
|
||||
|
||||
Configuration
|
||||
@@ -1306,7 +1385,7 @@ Background Server
|
||||
[ok] app-server background server is not running
|
||||
|
||||
{}
|
||||
9 ok | 2 notes | 1 warn | 1 fail failed
|
||||
12 ok | 2 notes | 1 warn | 1 fail failed
|
||||
|
||||
Run codex doctor without --summary for detailed diagnostics.
|
||||
--all expand truncated lists --json redacted report
|
||||
|
||||
@@ -37,8 +37,11 @@ struct ParsedDetail {
|
||||
pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec<HumanDetail> {
|
||||
let parsed = parsed_details(check);
|
||||
let details = match check.category.as_str() {
|
||||
"system" => system_details(&parsed),
|
||||
"runtime" => runtime_details(&parsed),
|
||||
"install" => install_details(&parsed, options),
|
||||
"git" => git_details(&parsed, options),
|
||||
"title" => title_details(&parsed),
|
||||
"config" => config_details(&parsed, options),
|
||||
"state" => state_details(&parsed),
|
||||
_ => generic_details(&parsed),
|
||||
@@ -52,6 +55,30 @@ pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) ->
|
||||
details
|
||||
}
|
||||
|
||||
fn system_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> {
|
||||
let mut out = Vec::new();
|
||||
push_row_if_present(&mut out, parsed, "os", "os");
|
||||
push_row_if_present(&mut out, parsed, "os language", "OS language");
|
||||
push_row_if_present(&mut out, parsed, "LC_ALL", "LC_ALL");
|
||||
push_row_if_present(&mut out, parsed, "LC_CTYPE", "LC_CTYPE");
|
||||
push_row_if_present(&mut out, parsed, "LANG", "LANG");
|
||||
push_remaining(
|
||||
&mut out,
|
||||
parsed,
|
||||
&[
|
||||
"os",
|
||||
"os type",
|
||||
"os version",
|
||||
"os language",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LANG",
|
||||
],
|
||||
&[],
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option<String> {
|
||||
parsed_details(check)
|
||||
.into_iter()
|
||||
@@ -232,6 +259,97 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<
|
||||
out
|
||||
}
|
||||
|
||||
fn git_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> {
|
||||
let mut out = Vec::new();
|
||||
push_row_if_present(&mut out, parsed, "selected git", "selected git");
|
||||
push_row_if_present(&mut out, parsed, "git version", "version");
|
||||
push_row_if_present(&mut out, parsed, "git exec path", "exec path");
|
||||
push_row_if_present(&mut out, parsed, "repo detected", "repo detected");
|
||||
push_row_if_present(&mut out, parsed, "repo root", "repo root");
|
||||
push_row_if_present(&mut out, parsed, ".git entry", ".git entry");
|
||||
push_row_if_present(&mut out, parsed, "git branch", "branch");
|
||||
push_row_if_present(&mut out, parsed, "core.fsmonitor", "core.fsmonitor");
|
||||
|
||||
let path_entries = numbered_values(parsed, "PATH git #");
|
||||
if !path_entries.is_empty() {
|
||||
let total = path_entries.len();
|
||||
let shown = if options.show_all {
|
||||
total
|
||||
} else {
|
||||
total.min(3)
|
||||
};
|
||||
out.push(HumanDetail::Row {
|
||||
label: format!("PATH entries ({total})"),
|
||||
value: path_entries[0].clone(),
|
||||
expected: None,
|
||||
});
|
||||
out.extend(
|
||||
path_entries
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(shown.saturating_sub(1))
|
||||
.cloned()
|
||||
.map(HumanDetail::Continuation),
|
||||
);
|
||||
if shown < total {
|
||||
out.push(HumanDetail::Continuation(
|
||||
"… (full list with --all)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
push_remaining(
|
||||
&mut out,
|
||||
parsed,
|
||||
&[
|
||||
"selected git",
|
||||
"PATH git entries",
|
||||
"git version",
|
||||
"git exec path",
|
||||
"git build options",
|
||||
"repo detected",
|
||||
"repo root",
|
||||
".git entry",
|
||||
"git branch",
|
||||
"core.fsmonitor",
|
||||
],
|
||||
&["PATH git #"],
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn title_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> {
|
||||
let mut out = Vec::new();
|
||||
push_row_if_present(&mut out, parsed, "terminal title source", "title source");
|
||||
push_row_if_present(&mut out, parsed, "terminal title items", "title items");
|
||||
push_row_if_present(&mut out, parsed, "terminal title activity", "activity item");
|
||||
push_row_if_present(
|
||||
&mut out,
|
||||
parsed,
|
||||
"terminal title project source",
|
||||
"project source",
|
||||
);
|
||||
push_row_if_present(
|
||||
&mut out,
|
||||
parsed,
|
||||
"terminal title project value",
|
||||
"project value",
|
||||
);
|
||||
push_remaining(
|
||||
&mut out,
|
||||
parsed,
|
||||
&[
|
||||
"terminal title source",
|
||||
"terminal title items",
|
||||
"terminal title activity",
|
||||
"terminal title project source",
|
||||
"terminal title project value",
|
||||
],
|
||||
&[],
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(model) = value(parsed, "model") {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
source: cli/src/doctor/output.rs
|
||||
expression: "render_human_report(&sample_report(), detailed_no_color_unicode_options())"
|
||||
---
|
||||
Codex Doctor v0.0.0
|
||||
|
||||
Notes
|
||||
⚠ terminal narrow terminal
|
||||
✗ auth token expired - Run `codex login`.
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
Environment
|
||||
✓ system en-US
|
||||
os macOS 15.0
|
||||
OS language en-US
|
||||
✓ runtime running local build on darwin-arm64
|
||||
✓ install consistent
|
||||
managed by npm: no · bun: no · package root —
|
||||
✓ search search is OK (bundled)
|
||||
✓ git git version 2.54.0
|
||||
selected git /usr/bin/git
|
||||
version git version 2.54.0
|
||||
repo detected true
|
||||
⚠ terminal narrow terminal
|
||||
✓ title default · project codex
|
||||
title source default
|
||||
title items activity, project-name
|
||||
project value codex
|
||||
✓ state state paths inspectable
|
||||
|
||||
Configuration
|
||||
✗ auth token expired — Run `codex login`.
|
||||
OPENAI_API_KEY present
|
||||
|
||||
Updates
|
||||
✓ updates update configuration is locally consistent
|
||||
|
||||
Connectivity
|
||||
✓ network network environment readable
|
||||
✓ websocket Responses WebSocket handshake succeeded
|
||||
✓ reachability active provider endpoints are reachable over HTTP
|
||||
|
||||
Background Server
|
||||
✓ app-server background server is not running
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
12 ok · 2 notes · 1 warn · 1 fail failed
|
||||
|
||||
--summary compact output --all expand truncated lists
|
||||
--json redacted report
|
||||
112
codex-rs/cli/src/doctor/system.rs
Normal file
112
codex-rs/cli/src/doctor/system.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
|
||||
use super::DoctorCheck;
|
||||
use super::LOCALE_ENV_VARS;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
struct SystemCheckInputs {
|
||||
os: String,
|
||||
os_type: String,
|
||||
os_version: String,
|
||||
os_language: Option<String>,
|
||||
locale_env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl SystemCheckInputs {
|
||||
fn detect() -> Self {
|
||||
let info = os_info::get();
|
||||
let locale_env = LOCALE_ENV_VARS
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| ((*name).to_string(), value))
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
os: info.to_string(),
|
||||
os_type: info.os_type().to_string(),
|
||||
os_version: info.version().to_string(),
|
||||
os_language: sys_locale::get_locale(),
|
||||
locale_env,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn system_check() -> DoctorCheck {
|
||||
system_check_from_inputs(SystemCheckInputs::detect())
|
||||
}
|
||||
|
||||
fn system_check_from_inputs(inputs: SystemCheckInputs) -> DoctorCheck {
|
||||
let mut details = vec![
|
||||
format!("os: {}", inputs.os),
|
||||
format!("os type: {}", inputs.os_type),
|
||||
format!("os version: {}", inputs.os_version),
|
||||
];
|
||||
if let Some(language) = inputs.os_language.as_deref() {
|
||||
details.push(format!("os language: {language}"));
|
||||
} else {
|
||||
details.push("os language: unavailable".to_string());
|
||||
}
|
||||
for name in LOCALE_ENV_VARS {
|
||||
if let Some(value) = inputs.locale_env.get(*name) {
|
||||
details.push(format!("{name}: {value}"));
|
||||
}
|
||||
}
|
||||
|
||||
let summary = inputs
|
||||
.os_language
|
||||
.as_deref()
|
||||
.map(|language| format!("OS language {language}"))
|
||||
.unwrap_or_else(|| "OS language unavailable".to_string());
|
||||
DoctorCheck::new(
|
||||
"system.environment",
|
||||
"system",
|
||||
super::CheckStatus::Ok,
|
||||
summary,
|
||||
)
|
||||
.details(details)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn system_check_reports_os_language_and_locale_env() {
|
||||
let mut locale_env = BTreeMap::new();
|
||||
locale_env.insert("LANG".to_string(), "en_US.UTF-8".to_string());
|
||||
let check = system_check_from_inputs(SystemCheckInputs {
|
||||
os: "macOS 15.0".to_string(),
|
||||
os_type: "macos".to_string(),
|
||||
os_version: "15.0".to_string(),
|
||||
os_language: Some("en-US".to_string()),
|
||||
locale_env,
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "OS language en-US");
|
||||
assert!(check.details.contains(&"os language: en-US".to_string()));
|
||||
assert!(check.details.contains(&"LANG: en_US.UTF-8".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_check_handles_missing_os_language() {
|
||||
let check = system_check_from_inputs(SystemCheckInputs {
|
||||
os: "Linux".to_string(),
|
||||
os_type: "linux".to_string(),
|
||||
os_version: "unknown".to_string(),
|
||||
os_language: None,
|
||||
locale_env: BTreeMap::new(),
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "OS language unavailable");
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"os language: unavailable".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
405
codex-rs/cli/src/doctor/title.rs
Normal file
405
codex-rs/cli/src/doctor/title.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_core::config::Config;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
|
||||
const DEFAULT_TERMINAL_TITLE_ITEMS: &[&str] = &["activity", "project-name"];
|
||||
const PROJECT_TITLE_MAX_CHARS: usize = 24;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct TerminalTitleInputs {
|
||||
configured_items: Option<Vec<String>>,
|
||||
cwd: PathBuf,
|
||||
project_root: Option<ProjectTitleRoot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct ProjectTitleRoot {
|
||||
source: &'static str,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn terminal_title_check(config: &Config) -> DoctorCheck {
|
||||
terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: config.tui_terminal_title.clone(),
|
||||
cwd: config.cwd.to_path_buf(),
|
||||
project_root: terminal_title_project_root(config, &config.cwd),
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_title_check_from_inputs(inputs: TerminalTitleInputs) -> DoctorCheck {
|
||||
let (source, items, invalid_items) = match inputs.configured_items {
|
||||
Some(items) if items.is_empty() => ("disabled", Vec::new(), Vec::new()),
|
||||
Some(items) => {
|
||||
let (items, invalid_items) = parse_terminal_title_items(items);
|
||||
("configured", items, invalid_items)
|
||||
}
|
||||
None => (
|
||||
"default",
|
||||
DEFAULT_TERMINAL_TITLE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
),
|
||||
};
|
||||
let mut details = vec![
|
||||
format!("terminal title source: {source}"),
|
||||
format!(
|
||||
"terminal title items: {}",
|
||||
if items.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
items.join(", ")
|
||||
}
|
||||
),
|
||||
format!("terminal title activity: {}", activity_enabled(&items)),
|
||||
];
|
||||
if !invalid_items.is_empty() {
|
||||
details.push(format!(
|
||||
"terminal title invalid items: {}",
|
||||
invalid_items.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if project_title_selected(&items) {
|
||||
let (project_source, project_value) =
|
||||
project_title_candidate(inputs.project_root, &inputs.cwd);
|
||||
details.push(format!("terminal title project source: {project_source}"));
|
||||
if let Some(project_value) = project_value {
|
||||
details.push(format!("terminal title project value: {project_value}"));
|
||||
}
|
||||
}
|
||||
|
||||
let status = if invalid_items.is_empty() {
|
||||
CheckStatus::Ok
|
||||
} else {
|
||||
CheckStatus::Warning
|
||||
};
|
||||
let summary = if invalid_items.is_empty() {
|
||||
format!("terminal title {source}")
|
||||
} else {
|
||||
format!("terminal title {source} with invalid items")
|
||||
};
|
||||
let mut check = DoctorCheck::new("terminal.title", "title", status, summary).details(details);
|
||||
if !invalid_items.is_empty() {
|
||||
check = check.issue(
|
||||
super::DoctorIssue::new(
|
||||
CheckStatus::Warning,
|
||||
"terminal title configuration contains unknown item identifiers",
|
||||
)
|
||||
.measured(invalid_items.join(", "))
|
||||
.expected("known terminal title item identifiers")
|
||||
.remedy("Remove or replace the unknown entries in [tui].terminal_title.")
|
||||
.field("terminal title invalid items"),
|
||||
);
|
||||
}
|
||||
check
|
||||
}
|
||||
|
||||
fn parse_terminal_title_items(items: Vec<String>) -> (Vec<String>, Vec<String>) {
|
||||
let mut invalid = Vec::new();
|
||||
let mut invalid_seen = HashSet::new();
|
||||
let mut parsed = Vec::new();
|
||||
for item in items {
|
||||
match terminal_title_item_id(&item) {
|
||||
Some(id) => parsed.push(id.to_string()),
|
||||
None => {
|
||||
if invalid_seen.insert(item.clone()) {
|
||||
invalid.push(format!(r#""{item}""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(parsed, invalid)
|
||||
}
|
||||
|
||||
fn terminal_title_item_id(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"app-name" => Some("app-name"),
|
||||
"project-name" | "project" => Some("project-name"),
|
||||
"current-dir" => Some("current-dir"),
|
||||
"activity" | "spinner" => Some("activity"),
|
||||
"run-state" | "status" => Some("run-state"),
|
||||
"thread-title" | "thread" => Some("thread-title"),
|
||||
"git-branch" => Some("git-branch"),
|
||||
"context-remaining" => Some("context-remaining"),
|
||||
"context-used" | "context-usage" => Some("context-used"),
|
||||
"five-hour-limit" => Some("five-hour-limit"),
|
||||
"weekly-limit" => Some("weekly-limit"),
|
||||
"codex-version" => Some("codex-version"),
|
||||
"used-tokens" => Some("used-tokens"),
|
||||
"total-input-tokens" => Some("total-input-tokens"),
|
||||
"total-output-tokens" => Some("total-output-tokens"),
|
||||
"thread-id" | "session-id" => Some("thread-id"),
|
||||
"fast-mode" => Some("fast-mode"),
|
||||
"model" | "model-name" => Some("model"),
|
||||
"model-with-reasoning" => Some("model-with-reasoning"),
|
||||
"task-progress" => Some("task-progress"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_enabled(items: &[String]) -> bool {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item == "activity" || item == "spinner")
|
||||
}
|
||||
|
||||
fn project_title_selected(items: &[String]) -> bool {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item == "project-name" || item == "project")
|
||||
}
|
||||
|
||||
fn terminal_title_project_root(config: &Config, cwd: &Path) -> Option<ProjectTitleRoot> {
|
||||
if let Some(repo_root) = get_git_repo_root(cwd) {
|
||||
return Some(ProjectTitleRoot {
|
||||
source: "git repo root",
|
||||
path: repo_root,
|
||||
});
|
||||
}
|
||||
|
||||
config
|
||||
.config_layer_stack
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.iter()
|
||||
.find_map(|layer| match &layer.name {
|
||||
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder
|
||||
.as_path()
|
||||
.parent()
|
||||
.map(|root| ProjectTitleRoot {
|
||||
source: "project config",
|
||||
path: root.to_path_buf(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_title_candidate(
|
||||
project_root: Option<ProjectTitleRoot>,
|
||||
cwd: &Path,
|
||||
) -> (&'static str, Option<String>) {
|
||||
if let Some(project_root) = project_root {
|
||||
return (
|
||||
project_root.source,
|
||||
Some(truncate_title_part(path_display_name(&project_root.path))),
|
||||
);
|
||||
}
|
||||
("cwd", Some(truncate_title_part(path_display_name(cwd))))
|
||||
}
|
||||
|
||||
fn path_display_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
fn truncate_title_part(value: String) -> String {
|
||||
let mut graphemes = value.graphemes(true);
|
||||
let head = graphemes
|
||||
.by_ref()
|
||||
.take(PROJECT_TITLE_MAX_CHARS)
|
||||
.collect::<String>();
|
||||
if graphemes.next().is_none() || PROJECT_TITLE_MAX_CHARS <= 3 {
|
||||
return head;
|
||||
}
|
||||
|
||||
let mut truncated = head
|
||||
.graphemes(true)
|
||||
.take(PROJECT_TITLE_MAX_CHARS - 3)
|
||||
.collect::<String>();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_title_reports_default_items_and_git_project_name() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: None,
|
||||
cwd: PathBuf::from("/repo/subdir"),
|
||||
project_root: Some(ProjectTitleRoot {
|
||||
source: "git repo root",
|
||||
path: PathBuf::from("/repo"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "terminal title default");
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title items: activity, project-name".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title project source: git repo root".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title project value: repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_reports_disabled_configuration() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(Vec::new()),
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
project_root: None,
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "terminal title disabled");
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title items: none".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title activity: false".to_string())
|
||||
);
|
||||
assert!(
|
||||
!check
|
||||
.details
|
||||
.iter()
|
||||
.any(|detail| detail.starts_with("terminal title project "))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_reports_project_config_fallback() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(vec!["project".to_string()]),
|
||||
cwd: PathBuf::from("/workspace/project/subdir"),
|
||||
project_root: Some(ProjectTitleRoot {
|
||||
source: "project config",
|
||||
path: PathBuf::from("/workspace/project"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "terminal title configured");
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title project source: project config".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title project value: project".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_omits_project_when_project_item_is_not_selected() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(vec!["model".to_string()]),
|
||||
cwd: PathBuf::from("/workspace/project"),
|
||||
project_root: Some(ProjectTitleRoot {
|
||||
source: "project config",
|
||||
path: PathBuf::from("/workspace/project"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(check.summary, "terminal title configured");
|
||||
assert!(
|
||||
!check
|
||||
.details
|
||||
.iter()
|
||||
.any(|detail| detail.starts_with("terminal title project "))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_warns_for_invalid_configured_items() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(vec![
|
||||
"project".to_string(),
|
||||
"bogus".to_string(),
|
||||
"activity".to_string(),
|
||||
"bogus".to_string(),
|
||||
]),
|
||||
cwd: PathBuf::from("/workspace/project"),
|
||||
project_root: Some(ProjectTitleRoot {
|
||||
source: "project config",
|
||||
path: PathBuf::from("/workspace/project"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Warning);
|
||||
assert_eq!(
|
||||
check.summary,
|
||||
"terminal title configured with invalid items"
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title items: project-name, activity".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&r#"terminal title invalid items: "bogus""#.to_string())
|
||||
);
|
||||
assert_eq!(check.issues.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_warns_when_all_configured_items_are_invalid() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(vec!["bogus".to_string()]),
|
||||
cwd: PathBuf::from("/workspace/project"),
|
||||
project_root: None,
|
||||
});
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Warning);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title items: none".to_string())
|
||||
);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&r#"terminal title invalid items: "bogus""#.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_project_value_uses_tui_truncation_shape() {
|
||||
let check = terminal_title_check_from_inputs(TerminalTitleInputs {
|
||||
configured_items: Some(vec!["project".to_string()]),
|
||||
cwd: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"),
|
||||
project_root: Some(ProjectTitleRoot {
|
||||
source: "project config",
|
||||
path: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.contains(&"terminal title project value: abcdefghijklmnopqrstu...".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,12 @@ pub struct TuiVimNormalKeymap {
|
||||
pub delete_char: Option<KeybindingsSpec>,
|
||||
/// Delete from cursor to end of line (`D`).
|
||||
pub delete_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Change from cursor to end of line and enter insert mode (`C`).
|
||||
pub change_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Substitute the character under the cursor and enter insert mode (`s`).
|
||||
pub substitute_char: Option<KeybindingsSpec>,
|
||||
/// Substitute the current line and enter insert mode (`S`).
|
||||
pub substitute_line: Option<KeybindingsSpec>,
|
||||
/// Yank the entire line (`Y`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Paste after cursor (`p`).
|
||||
@@ -231,14 +237,16 @@ pub struct TuiVimNormalKeymap {
|
||||
pub start_delete_operator: Option<KeybindingsSpec>,
|
||||
/// Begin yank operator; next key selects motion (`y`).
|
||||
pub start_yank_operator: Option<KeybindingsSpec>,
|
||||
/// Begin change operator; next keys select a motion or text object.
|
||||
pub start_change_operator: Option<KeybindingsSpec>,
|
||||
/// Cancel a pending operator and return to normal mode.
|
||||
pub cancel_operator: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim operator-pending keybindings for modal editing inside text areas.
|
||||
///
|
||||
/// This context is active only while waiting for a motion after `d` or `y`.
|
||||
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
|
||||
/// This context is active only while waiting for a motion after `d`, `y`, or `c`.
|
||||
/// Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing
|
||||
/// `Esc` cancels the pending operator and returns to normal mode without
|
||||
/// modifying text.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
@@ -248,6 +256,8 @@ pub struct TuiVimOperatorKeymap {
|
||||
pub delete_line: Option<KeybindingsSpec>,
|
||||
/// Repeat yank operator to yank the whole line (`yy`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Repeat change operator to change the whole line (`cc`).
|
||||
pub change_line: Option<KeybindingsSpec>,
|
||||
/// Motion: left (`h`).
|
||||
pub motion_left: Option<KeybindingsSpec>,
|
||||
/// Motion: right (`l`).
|
||||
@@ -266,10 +276,39 @@ pub struct TuiVimOperatorKeymap {
|
||||
pub motion_line_start: Option<KeybindingsSpec>,
|
||||
/// Motion: to end of line (`$`).
|
||||
pub motion_line_end: Option<KeybindingsSpec>,
|
||||
/// Select an inner text object after an operator.
|
||||
pub select_inner_text_object: Option<KeybindingsSpec>,
|
||||
/// Select an around text object after an operator.
|
||||
pub select_around_text_object: Option<KeybindingsSpec>,
|
||||
/// Cancel the pending operator and return to normal mode.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim text-object keybindings for modal editing inside text areas.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiVimTextObjectKeymap {
|
||||
/// Text object: word.
|
||||
pub word: Option<KeybindingsSpec>,
|
||||
/// Text object: whitespace-delimited WORD.
|
||||
pub big_word: Option<KeybindingsSpec>,
|
||||
/// Text object: parentheses.
|
||||
pub parentheses: Option<KeybindingsSpec>,
|
||||
/// Text object: brackets.
|
||||
pub brackets: Option<KeybindingsSpec>,
|
||||
/// Text object: braces.
|
||||
pub braces: Option<KeybindingsSpec>,
|
||||
/// Text object: double quotes.
|
||||
pub double_quote: Option<KeybindingsSpec>,
|
||||
/// Text object: single quotes.
|
||||
pub single_quote: Option<KeybindingsSpec>,
|
||||
/// Text object: backticks.
|
||||
pub backtick: Option<KeybindingsSpec>,
|
||||
/// Cancel the pending text-object command.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Pager context keybindings for transcript and static overlays.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -374,6 +413,8 @@ pub struct TuiKeymap {
|
||||
#[serde(default)]
|
||||
pub vim_operator: TuiVimOperatorKeymap,
|
||||
#[serde(default)]
|
||||
pub vim_text_object: TuiVimTextObjectKeymap,
|
||||
#[serde(default)]
|
||||
pub pager: TuiPagerKeymap,
|
||||
#[serde(default)]
|
||||
pub list: TuiListKeymap,
|
||||
@@ -560,6 +601,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelled_vim_text_object_action_is_rejected() {
|
||||
let toml_input = r#"
|
||||
[vim_text_object]
|
||||
double_quotes = "shift-quote"
|
||||
"#;
|
||||
let err = toml::from_str::<TuiKeymap>(toml_input)
|
||||
.expect_err("expected unknown vim text object action");
|
||||
assert!(
|
||||
err.to_string().contains("double_quotes"),
|
||||
"expected error to mention misspelled field, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_backtrack_actions_are_rejected() {
|
||||
for (context, action) in [
|
||||
|
||||
@@ -2824,6 +2824,7 @@
|
||||
"append_after_cursor": null,
|
||||
"append_line_end": null,
|
||||
"cancel_operator": null,
|
||||
"change_to_line_end": null,
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
@@ -2840,12 +2841,16 @@
|
||||
"open_line_above": null,
|
||||
"open_line_below": null,
|
||||
"paste_after": null,
|
||||
"start_change_operator": null,
|
||||
"start_delete_operator": null,
|
||||
"start_yank_operator": null,
|
||||
"substitute_char": null,
|
||||
"substitute_line": null,
|
||||
"yank_line": null
|
||||
},
|
||||
"vim_operator": {
|
||||
"cancel": null,
|
||||
"change_line": null,
|
||||
"delete_line": null,
|
||||
"motion_down": null,
|
||||
"motion_left": null,
|
||||
@@ -2856,7 +2861,20 @@
|
||||
"motion_word_backward": null,
|
||||
"motion_word_end": null,
|
||||
"motion_word_forward": null,
|
||||
"select_around_text_object": null,
|
||||
"select_inner_text_object": null,
|
||||
"yank_line": null
|
||||
},
|
||||
"vim_text_object": {
|
||||
"backtick": null,
|
||||
"big_word": null,
|
||||
"braces": null,
|
||||
"brackets": null,
|
||||
"cancel": null,
|
||||
"double_quote": null,
|
||||
"parentheses": null,
|
||||
"single_quote": null,
|
||||
"word": null
|
||||
}
|
||||
},
|
||||
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
|
||||
@@ -3490,6 +3508,7 @@
|
||||
"append_after_cursor": null,
|
||||
"append_line_end": null,
|
||||
"cancel_operator": null,
|
||||
"change_to_line_end": null,
|
||||
"delete_char": null,
|
||||
"delete_to_line_end": null,
|
||||
"enter_insert": null,
|
||||
@@ -3506,8 +3525,11 @@
|
||||
"open_line_above": null,
|
||||
"open_line_below": null,
|
||||
"paste_after": null,
|
||||
"start_change_operator": null,
|
||||
"start_delete_operator": null,
|
||||
"start_yank_operator": null,
|
||||
"substitute_char": null,
|
||||
"substitute_line": null,
|
||||
"yank_line": null
|
||||
}
|
||||
},
|
||||
@@ -3519,6 +3541,7 @@
|
||||
],
|
||||
"default": {
|
||||
"cancel": null,
|
||||
"change_line": null,
|
||||
"delete_line": null,
|
||||
"motion_down": null,
|
||||
"motion_left": null,
|
||||
@@ -3529,8 +3552,28 @@
|
||||
"motion_word_backward": null,
|
||||
"motion_word_end": null,
|
||||
"motion_word_forward": null,
|
||||
"select_around_text_object": null,
|
||||
"select_inner_text_object": null,
|
||||
"yank_line": null
|
||||
}
|
||||
},
|
||||
"vim_text_object": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TuiVimTextObjectKeymap"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"backtick": null,
|
||||
"big_word": null,
|
||||
"braces": null,
|
||||
"brackets": null,
|
||||
"cancel": null,
|
||||
"double_quote": null,
|
||||
"parentheses": null,
|
||||
"single_quote": null,
|
||||
"word": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -3755,6 +3798,14 @@
|
||||
],
|
||||
"description": "Cancel a pending operator and return to normal mode."
|
||||
},
|
||||
"change_to_line_end": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Change from cursor to end of line and enter insert mode (`C`)."
|
||||
},
|
||||
"delete_char": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3883,6 +3934,14 @@
|
||||
],
|
||||
"description": "Paste after cursor (`p`)."
|
||||
},
|
||||
"start_change_operator": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Begin change operator; next keys select a motion or text object."
|
||||
},
|
||||
"start_delete_operator": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3899,6 +3958,22 @@
|
||||
],
|
||||
"description": "Begin yank operator; next key selects motion (`y`)."
|
||||
},
|
||||
"substitute_char": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Substitute the character under the cursor and enter insert mode (`s`)."
|
||||
},
|
||||
"substitute_line": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Substitute the current line and enter insert mode (`S`)."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3912,7 +3987,7 @@
|
||||
},
|
||||
"TuiVimOperatorKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
|
||||
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d`, `y`, or `c`. Repeating the operator key (`dd`, `yy`, `cc`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
|
||||
"properties": {
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
@@ -3922,6 +3997,14 @@
|
||||
],
|
||||
"description": "Cancel the pending operator and return to normal mode."
|
||||
},
|
||||
"change_line": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Repeat change operator to change the whole line (`cc`)."
|
||||
},
|
||||
"delete_line": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -4002,6 +4085,22 @@
|
||||
],
|
||||
"description": "Motion: to start of next word (`w`)."
|
||||
},
|
||||
"select_around_text_object": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Select an around text object after an operator."
|
||||
},
|
||||
"select_inner_text_object": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Select an inner text object after an operator."
|
||||
},
|
||||
"yank_line": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -4013,6 +4112,85 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"TuiVimTextObjectKeymap": {
|
||||
"additionalProperties": false,
|
||||
"description": "Vim text-object keybindings for modal editing inside text areas.",
|
||||
"properties": {
|
||||
"backtick": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: backticks."
|
||||
},
|
||||
"big_word": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: whitespace-delimited WORD."
|
||||
},
|
||||
"braces": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: braces."
|
||||
},
|
||||
"brackets": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: brackets."
|
||||
},
|
||||
"cancel": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Cancel the pending text-object command."
|
||||
},
|
||||
"double_quote": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: double quotes."
|
||||
},
|
||||
"parentheses": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: parentheses."
|
||||
},
|
||||
"single_quote": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: single quotes."
|
||||
},
|
||||
"word": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Text object: word."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UriBasedFileOpener": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea.rs
|
||||
expression: "states.join(\"\\n\\n\")"
|
||||
---
|
||||
alpha beta gamma
|
||||
^
|
||||
|
||||
alpha beta gamma
|
||||
^
|
||||
|
||||
alpha beta gamma
|
||||
^
|
||||
@@ -16,6 +16,7 @@ use crate::keymap::EditorKeymap;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::VimNormalKeymap;
|
||||
use crate::keymap::VimOperatorKeymap;
|
||||
use crate::keymap::VimTextObjectKeymap;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -35,6 +36,13 @@ use textwrap::Options;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
mod vim;
|
||||
use self::vim::VimMode;
|
||||
use self::vim::VimMotion;
|
||||
use self::vim::VimOperator;
|
||||
use self::vim::VimPending;
|
||||
use self::vim::VimTextObjectScope;
|
||||
|
||||
const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
|
||||
|
||||
fn is_word_separator(ch: char) -> bool {
|
||||
@@ -101,10 +109,11 @@ pub(crate) struct TextArea {
|
||||
kill_buffer_kind: KillBufferKind,
|
||||
vim_enabled: bool,
|
||||
vim_mode: VimMode,
|
||||
vim_operator: Option<VimOperator>,
|
||||
vim_pending: VimPending,
|
||||
editor_keymap: EditorKeymap,
|
||||
vim_normal_keymap: VimNormalKeymap,
|
||||
vim_operator_keymap: VimOperatorKeymap,
|
||||
vim_text_object_keymap: VimTextObjectKeymap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -119,14 +128,6 @@ pub(crate) struct TextAreaState {
|
||||
scroll: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMode {
|
||||
/// Normal mode routes printable keys to movement, operators, and mode transitions.
|
||||
Normal,
|
||||
/// Insert mode routes input through the regular editor keymap until Escape is pressed.
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum KillBufferKind {
|
||||
/// Characterwise kills and yanks paste at the cursor.
|
||||
@@ -135,36 +136,6 @@ enum KillBufferKind {
|
||||
Linewise,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimOperator {
|
||||
/// Delete the range selected by the next motion or repeated operator key.
|
||||
Delete,
|
||||
/// Copy the range selected by the next motion or repeated operator key.
|
||||
Yank,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum VimMotion {
|
||||
/// Move one atomic boundary to the left.
|
||||
Left,
|
||||
/// Move one atomic boundary to the right.
|
||||
Right,
|
||||
/// Move one visual row up, preserving preferred display column.
|
||||
Up,
|
||||
/// Move one visual row down, preserving preferred display column.
|
||||
Down,
|
||||
/// Move to the start of the next word-like run.
|
||||
WordForward,
|
||||
/// Move to the start of the previous word-like run.
|
||||
WordBackward,
|
||||
/// Move to the end of the current or next word-like run.
|
||||
WordEnd,
|
||||
/// Move to the start of the current line.
|
||||
LineStart,
|
||||
/// Move to the end of the current line.
|
||||
LineEnd,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new() -> Self {
|
||||
let defaults = RuntimeKeymap::defaults();
|
||||
@@ -179,10 +150,11 @@ impl TextArea {
|
||||
kill_buffer_kind: KillBufferKind::Characterwise,
|
||||
vim_enabled: false,
|
||||
vim_mode: VimMode::Insert,
|
||||
vim_operator: None,
|
||||
vim_pending: VimPending::None,
|
||||
editor_keymap: defaults.editor,
|
||||
vim_normal_keymap: defaults.vim_normal,
|
||||
vim_operator_keymap: defaults.vim_operator,
|
||||
vim_text_object_keymap: defaults.vim_text_object,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +168,7 @@ impl TextArea {
|
||||
self.editor_keymap = keymap.editor.clone();
|
||||
self.vim_normal_keymap = keymap.vim_normal.clone();
|
||||
self.vim_operator_keymap = keymap.vim_operator.clone();
|
||||
self.vim_text_object_keymap = keymap.vim_text_object.clone();
|
||||
}
|
||||
|
||||
/// Replace the visible textarea text and clear any existing text elements.
|
||||
@@ -257,7 +230,7 @@ impl TextArea {
|
||||
/// an old `d` or `y` command.
|
||||
pub(crate) fn set_vim_enabled(&mut self, enabled: bool) {
|
||||
self.vim_enabled = enabled;
|
||||
self.vim_operator = None;
|
||||
self.vim_pending = VimPending::None;
|
||||
self.vim_mode = if enabled {
|
||||
VimMode::Normal
|
||||
} else {
|
||||
@@ -293,7 +266,7 @@ impl TextArea {
|
||||
/// This is observable so the composer can avoid stealing the second key of
|
||||
/// `d{motion}` or `y{motion}` for higher-level shortcuts.
|
||||
pub(crate) fn is_vim_operator_pending(&self) -> bool {
|
||||
self.vim_operator.is_some()
|
||||
!matches!(self.vim_pending, VimPending::None)
|
||||
}
|
||||
|
||||
/// Enter Vim insert mode if modal editing is enabled.
|
||||
@@ -304,7 +277,7 @@ impl TextArea {
|
||||
pub(crate) fn enter_vim_insert_mode(&mut self) {
|
||||
if self.vim_enabled {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
self.vim_operator = None;
|
||||
self.vim_pending = VimPending::None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +290,7 @@ impl TextArea {
|
||||
pub(crate) fn enter_vim_normal_mode(&mut self) {
|
||||
if self.vim_enabled {
|
||||
self.vim_mode = VimMode::Normal;
|
||||
self.vim_operator = None;
|
||||
self.vim_pending = VimPending::None;
|
||||
self.preferred_col = None;
|
||||
}
|
||||
}
|
||||
@@ -669,9 +642,17 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn handle_vim_normal(&mut self, event: KeyEvent) {
|
||||
if let Some(op) = self.vim_operator.take() {
|
||||
self.handle_vim_operator(op, event);
|
||||
return;
|
||||
let pending = std::mem::replace(&mut self.vim_pending, VimPending::None);
|
||||
match pending {
|
||||
VimPending::None => {}
|
||||
VimPending::Operator(op) => {
|
||||
self.handle_vim_operator(op, event);
|
||||
return;
|
||||
}
|
||||
VimPending::TextObject { operator, scope } => {
|
||||
self.handle_vim_text_object(operator, scope, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.vim_normal_keymap.enter_insert.is_pressed(event) {
|
||||
@@ -755,7 +736,20 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) {
|
||||
self.kill_to_end_of_line();
|
||||
self.vim_kill_to_end_of_line();
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.change_to_line_end.is_pressed(event) {
|
||||
self.apply_vim_operator(VimOperator::Change, VimMotion::LineEnd);
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.substitute_char.is_pressed(event) {
|
||||
self.delete_forward_kill(/*n*/ 1);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.substitute_line.is_pressed(event) {
|
||||
self.change_current_line();
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.yank_line.is_pressed(event) {
|
||||
@@ -771,15 +765,23 @@ impl TextArea {
|
||||
.start_delete_operator
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.vim_operator = Some(VimOperator::Delete);
|
||||
self.vim_pending = VimPending::Operator(VimOperator::Delete);
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.start_yank_operator.is_pressed(event) {
|
||||
self.vim_operator = Some(VimOperator::Yank);
|
||||
self.vim_pending = VimPending::Operator(VimOperator::Yank);
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.vim_normal_keymap
|
||||
.start_change_operator
|
||||
.is_pressed(event)
|
||||
{
|
||||
self.vim_pending = VimPending::Operator(VimOperator::Change);
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.cancel_operator.is_pressed(event) {
|
||||
self.vim_operator = None;
|
||||
self.vim_pending = VimPending::None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,9 +794,20 @@ impl TextArea {
|
||||
self.yank_current_line();
|
||||
return true;
|
||||
}
|
||||
if op == VimOperator::Change && self.vim_operator_keymap.change_line.is_pressed(event) {
|
||||
self.change_current_line();
|
||||
return true;
|
||||
}
|
||||
if self.vim_operator_keymap.cancel.is_pressed(event) {
|
||||
return true;
|
||||
}
|
||||
if let Some(scope) = self.vim_text_object_scope_for_event(event) {
|
||||
self.vim_pending = VimPending::TextObject {
|
||||
operator: op,
|
||||
scope,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(motion) = self.vim_motion_for_event(event) {
|
||||
self.apply_vim_operator(op, motion);
|
||||
@@ -803,6 +816,24 @@ impl TextArea {
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_vim_text_object(
|
||||
&mut self,
|
||||
op: VimOperator,
|
||||
scope: VimTextObjectScope,
|
||||
event: KeyEvent,
|
||||
) -> bool {
|
||||
if self.vim_text_object_keymap.cancel.is_pressed(event) {
|
||||
return true;
|
||||
}
|
||||
let Some(object) = self.vim_text_object_for_event(event) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(range) = self.text_object_range(object, scope) {
|
||||
self.apply_vim_operator_to_range(op, range);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn vim_motion_for_event(&self, event: KeyEvent) -> Option<VimMotion> {
|
||||
if self.vim_operator_keymap.motion_left.is_pressed(event) {
|
||||
return Some(VimMotion::Left);
|
||||
@@ -843,12 +874,49 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn apply_vim_operator(&mut self, op: VimOperator, motion: VimMotion) {
|
||||
let Some(range) = self.range_for_motion(motion) else {
|
||||
return;
|
||||
};
|
||||
match op {
|
||||
VimOperator::Delete => {
|
||||
if let Some(range) = self.range_for_motion(motion) {
|
||||
self.kill_range(range);
|
||||
}
|
||||
}
|
||||
VimOperator::Yank => {
|
||||
if let Some(range) = self.range_for_motion(motion) {
|
||||
self.yank_range(range);
|
||||
}
|
||||
}
|
||||
VimOperator::Change => {
|
||||
if let Some(range) = self.range_for_change_motion(motion) {
|
||||
self.kill_range(range);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
} else if motion == VimMotion::LineEnd {
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn range_for_change_motion(&mut self, motion: VimMotion) -> Option<Range<usize>> {
|
||||
if motion == VimMotion::WordForward
|
||||
&& self.text[self.cursor_pos..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|ch| !ch.is_whitespace())
|
||||
{
|
||||
let end = self.end_of_next_word();
|
||||
return (self.cursor_pos < end).then_some(self.cursor_pos..end);
|
||||
}
|
||||
self.range_for_motion(motion)
|
||||
}
|
||||
|
||||
fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range<usize>) {
|
||||
match op {
|
||||
VimOperator::Delete => self.kill_range(range),
|
||||
VimOperator::Yank => self.yank_range(range),
|
||||
VimOperator::Change => {
|
||||
self.kill_range(range);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,7 +982,7 @@ impl TextArea {
|
||||
VimMotion::Down => self.move_cursor_down(),
|
||||
VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()),
|
||||
VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()),
|
||||
VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()),
|
||||
VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()),
|
||||
VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()),
|
||||
}
|
||||
@@ -1007,6 +1075,13 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_kill_to_end_of_line(&mut self) {
|
||||
let eol = self.end_of_current_line();
|
||||
if self.cursor_pos < eol {
|
||||
self.kill_range(self.cursor_pos..eol);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill_to_beginning_of_line(&mut self) {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let range = if self.cursor_pos == bol {
|
||||
@@ -1126,6 +1201,12 @@ impl TextArea {
|
||||
self.kill_line_range(range);
|
||||
}
|
||||
|
||||
fn change_current_line(&mut self) {
|
||||
let range = self.beginning_of_current_line()..self.end_of_current_line();
|
||||
self.kill_line_range(range);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
|
||||
fn current_line_range_with_newline(&self) -> Range<usize> {
|
||||
let bol = self.beginning_of_current_line();
|
||||
let eol = self.end_of_current_line();
|
||||
@@ -1734,7 +1815,11 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(crate) fn end_of_next_word(&self) -> usize {
|
||||
let suffix = &self.text[self.cursor_pos..];
|
||||
self.end_of_next_word_from(self.cursor_pos)
|
||||
}
|
||||
|
||||
fn end_of_next_word_from(&self, cursor_pos: usize) -> usize {
|
||||
let suffix = &self.text[cursor_pos..];
|
||||
let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
|
||||
return self.text.len();
|
||||
};
|
||||
@@ -1742,16 +1827,16 @@ impl TextArea {
|
||||
let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
|
||||
let mut pieces = split_word_pieces(run).into_iter().peekable();
|
||||
let Some((start, piece)) = pieces.next() else {
|
||||
return self.cursor_pos + first_non_ws;
|
||||
return cursor_pos + first_non_ws;
|
||||
};
|
||||
let word_start = self.cursor_pos + first_non_ws + start;
|
||||
let word_start = cursor_pos + first_non_ws + start;
|
||||
let mut end = word_start + piece.len();
|
||||
if piece.chars().all(is_word_separator) {
|
||||
while let Some((idx, piece)) = pieces.peek() {
|
||||
if !piece.chars().all(is_word_separator) {
|
||||
break;
|
||||
}
|
||||
end = self.cursor_pos + first_non_ws + *idx + piece.len();
|
||||
end = cursor_pos + first_non_ws + *idx + piece.len();
|
||||
pieces.next();
|
||||
}
|
||||
}
|
||||
@@ -1759,8 +1844,22 @@ impl TextArea {
|
||||
self.adjust_pos_out_of_elements(end, /*prefer_start*/ false)
|
||||
}
|
||||
|
||||
fn vim_word_end_cursor(&self) -> usize {
|
||||
fn vim_word_end_exclusive(&self) -> usize {
|
||||
let end = self.end_of_next_word();
|
||||
let target = if end > self.cursor_pos {
|
||||
self.prev_atomic_boundary(end)
|
||||
} else {
|
||||
end
|
||||
};
|
||||
if target == self.cursor_pos && end < self.text.len() {
|
||||
self.end_of_next_word_from(end)
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
fn vim_word_end_cursor(&self) -> usize {
|
||||
let end = self.vim_word_end_exclusive();
|
||||
if end > self.cursor_pos {
|
||||
self.prev_atomic_boundary(end)
|
||||
} else {
|
||||
@@ -2202,7 +2301,7 @@ mod tests {
|
||||
let mut t = TextArea::new();
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
@@ -2275,6 +2374,60 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() {
|
||||
let mut t = ta_with("hello world\nnext line");
|
||||
t.set_cursor(/*pos*/ 6);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT));
|
||||
|
||||
assert_eq!(t.text(), "hello \nnext line");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 6);
|
||||
assert_eq!(t.kill_buffer, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_uppercase_c_changes_to_line_end() {
|
||||
let mut t = ta_with("hello world\nnext line");
|
||||
t.set_cursor(/*pos*/ 6);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello \nnext line");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_d_at_line_end_does_not_remove_newline() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.set_cursor(/*pos*/ "hello".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello\nworld");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_c_at_line_end_enters_insert_without_removing_newline() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
t.set_cursor(/*pos*/ "hello".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello\nworld");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
assert_eq!(t.cursor(), "hello".len());
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_shift_o_opens_line_above_with_shift_only_binding() {
|
||||
let mut t = ta_with("hello\nworld");
|
||||
@@ -2315,6 +2468,245 @@ mod tests {
|
||||
assert_eq!(t.kill_buffer, "hello ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_change_inner_word_deletes_word_and_enters_insert() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ "hello ".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello ");
|
||||
assert_eq!(t.kill_buffer, "world");
|
||||
assert_eq!(t.cursor(), "hello ".len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_change_motion_and_linewise_aliases_enter_insert_mode() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), " world");
|
||||
assert_eq!(t.kill_buffer, "hello");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("hello world\nnext line");
|
||||
t.set_cursor(/*pos*/ 6);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello \nnext line");
|
||||
assert_eq!(t.kill_buffer, "world");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("one\ntwo\nthree");
|
||||
t.set_cursor(/*pos*/ "one\nt".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "one\n\nthree");
|
||||
assert_eq!(t.kill_buffer, "two");
|
||||
assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise);
|
||||
assert_eq!(t.cursor(), "one\n".len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("one\ntwo\nthree");
|
||||
t.set_cursor(/*pos*/ "one\nt".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "one\n\nthree");
|
||||
assert_eq!(t.kill_buffer, "two");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_substitute_char_preserves_grapheme_and_atomic_boundaries() {
|
||||
let mut t = ta_with("👍 ok");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), " ok");
|
||||
assert_eq!(t.kill_buffer, "👍");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("@file rest");
|
||||
t.add_element_range(0.."@file".len())
|
||||
.expect("valid element");
|
||||
t.set_cursor(/*pos*/ 0);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), " rest");
|
||||
assert_eq!(t.kill_buffer, "@file");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_word_text_objects_cover_delete_yank_and_big_word() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello world");
|
||||
assert_eq!(t.kill_buffer, "hello ");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
|
||||
let mut t = ta_with("foo.bar/baz qux");
|
||||
t.set_cursor(/*pos*/ "foo.".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), " qux");
|
||||
assert_eq!(t.kill_buffer, "foo.bar/baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_word_text_objects_accept_cursor_at_word_end() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ "hello".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "world");
|
||||
assert_eq!(t.kill_buffer, "hello ");
|
||||
|
||||
let mut t = ta_with("foo bar");
|
||||
t.set_cursor(t.text().len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "foo ");
|
||||
assert_eq!(t.kill_buffer, "bar");
|
||||
assert_eq!(t.cursor(), "foo ".len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delimiter_text_objects_select_innermost_pair_and_aliases() {
|
||||
let mut t = ta_with("a(b(c)d)e");
|
||||
t.set_cursor(/*pos*/ "a(b(".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "a(b()d)e");
|
||||
assert_eq!(t.kill_buffer, "c");
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("a [b] c");
|
||||
t.set_cursor(/*pos*/ "a [".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "a c");
|
||||
assert_eq!(t.kill_buffer, "[b]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_empty_inner_text_objects_are_valid_targets() {
|
||||
let mut t = ta_with("call()");
|
||||
t.set_cursor(/*pos*/ "call(".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('('), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "call()");
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
assert_eq!(t.cursor(), "call(".len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with(r#"say "" now"#);
|
||||
t.set_cursor(/*pos*/ r#"say ""#.len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), r#"say "" now"#);
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
assert_eq!(t.cursor(), r#"say ""#.len());
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_quote_text_objects_are_line_local_and_handle_escapes() {
|
||||
let mut t = ta_with(r#"say "a \"b\" c" now"#);
|
||||
t.set_cursor(/*pos*/ r#"say "a \"#.len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::SHIFT));
|
||||
|
||||
assert_eq!(t.text(), r#"say "" now"#);
|
||||
assert_eq!(t.kill_buffer, r#"a \"b\" c"#);
|
||||
assert_eq!(t.vim_mode_label(), Some("Insert"));
|
||||
|
||||
let mut t = ta_with("one \"two\nthree\" four");
|
||||
t.set_cursor(/*pos*/ "one \"two\n".len());
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "one \"two\nthree\" four");
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_text_object_cancellation_does_not_edit() {
|
||||
let mut t = ta_with("hello world");
|
||||
t.set_cursor(/*pos*/ 1);
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
assert!(t.is_vim_operator_pending());
|
||||
t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello world");
|
||||
assert_eq!(t.kill_buffer, "");
|
||||
assert!(!t.is_vim_operator_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_operator_invalid_motion_is_consumed() {
|
||||
let mut t = ta_with("hello");
|
||||
@@ -2324,7 +2716,7 @@ mod tests {
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert!(t.is_vim_operator_pending());
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "hello");
|
||||
assert_eq!(t.vim_mode_label(), Some("Normal"));
|
||||
@@ -2348,6 +2740,62 @@ mod tests {
|
||||
assert_eq!(t.kill_buffer, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_advances_from_each_word_end() {
|
||||
let mut t = ta_with("alpha beta gamma");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
let mut states = Vec::new();
|
||||
|
||||
for _ in 0..3 {
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor())));
|
||||
}
|
||||
|
||||
insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_delete_to_word_end_advances_from_existing_word_end() {
|
||||
let mut t = ta_with("alpha beta gamma");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph
|
||||
assert_eq!(t.kill_buffer, "a beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_from_word_end_can_land_on_trailing_space() {
|
||||
let mut t = ta_with("alpha ");
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(t.cursor(), "alpha ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_e_advances_across_atomic_element_word_ends() {
|
||||
let mut t = TextArea::new();
|
||||
t.insert_str("alpha ");
|
||||
t.insert_element("<element>");
|
||||
t.insert_str(" gamma");
|
||||
let element_start = t.elements[0].range.start;
|
||||
t.set_cursor("alph".len()); // codespell:ignore alph
|
||||
t.set_vim_enabled(/*enabled*/ true);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), element_start);
|
||||
|
||||
t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE));
|
||||
assert_eq!(t.cursor(), "alpha <element> gamm".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_dollar_lands_on_line_end_character() {
|
||||
let mut t = ta_with("abc\n123");
|
||||
|
||||
320
codex-rs/tui/src/bottom_pane/textarea/vim.rs
Normal file
320
codex-rs/tui/src/bottom_pane/textarea/vim.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
use super::TextArea;
|
||||
use super::split_word_pieces;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::ops::Range;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimMode {
|
||||
/// Normal mode routes printable keys to movement, operators, and mode transitions.
|
||||
Normal,
|
||||
/// Insert mode routes input through the regular editor keymap until Escape is pressed.
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimOperator {
|
||||
Delete,
|
||||
Yank,
|
||||
Change,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimPending {
|
||||
None,
|
||||
Operator(VimOperator),
|
||||
TextObject {
|
||||
operator: VimOperator,
|
||||
scope: VimTextObjectScope,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimMotion {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
WordForward,
|
||||
WordBackward,
|
||||
WordEnd,
|
||||
LineStart,
|
||||
LineEnd,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimTextObjectScope {
|
||||
Inner,
|
||||
Around,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum VimTextObject {
|
||||
Word,
|
||||
BigWord,
|
||||
Parentheses,
|
||||
Brackets,
|
||||
Braces,
|
||||
DoubleQuote,
|
||||
SingleQuote,
|
||||
Backtick,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub(super) fn vim_text_object_scope_for_event(
|
||||
&self,
|
||||
event: KeyEvent,
|
||||
) -> Option<VimTextObjectScope> {
|
||||
if self
|
||||
.vim_operator_keymap
|
||||
.select_inner_text_object
|
||||
.is_pressed(event)
|
||||
{
|
||||
return Some(VimTextObjectScope::Inner);
|
||||
}
|
||||
if self
|
||||
.vim_operator_keymap
|
||||
.select_around_text_object
|
||||
.is_pressed(event)
|
||||
{
|
||||
return Some(VimTextObjectScope::Around);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option<VimTextObject> {
|
||||
if self.vim_text_object_keymap.word.is_pressed(event) {
|
||||
return Some(VimTextObject::Word);
|
||||
}
|
||||
if self.vim_text_object_keymap.big_word.is_pressed(event) {
|
||||
return Some(VimTextObject::BigWord);
|
||||
}
|
||||
if self.vim_text_object_keymap.parentheses.is_pressed(event) {
|
||||
return Some(VimTextObject::Parentheses);
|
||||
}
|
||||
if self.vim_text_object_keymap.brackets.is_pressed(event) {
|
||||
return Some(VimTextObject::Brackets);
|
||||
}
|
||||
if self.vim_text_object_keymap.braces.is_pressed(event) {
|
||||
return Some(VimTextObject::Braces);
|
||||
}
|
||||
if self.vim_text_object_keymap.double_quote.is_pressed(event) {
|
||||
return Some(VimTextObject::DoubleQuote);
|
||||
}
|
||||
if self.vim_text_object_keymap.single_quote.is_pressed(event) {
|
||||
return Some(VimTextObject::SingleQuote);
|
||||
}
|
||||
if self.vim_text_object_keymap.backtick.is_pressed(event) {
|
||||
return Some(VimTextObject::Backtick);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn text_object_range(
|
||||
&self,
|
||||
object: VimTextObject,
|
||||
scope: VimTextObjectScope,
|
||||
) -> Option<Range<usize>> {
|
||||
match object {
|
||||
VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false),
|
||||
VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true),
|
||||
VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'),
|
||||
VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'),
|
||||
VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'),
|
||||
VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'),
|
||||
VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''),
|
||||
VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'),
|
||||
}
|
||||
}
|
||||
|
||||
fn word_text_object_range(
|
||||
&self,
|
||||
scope: VimTextObjectScope,
|
||||
big_word: bool,
|
||||
) -> Option<Range<usize>> {
|
||||
let inner = if big_word {
|
||||
self.big_word_range_at_cursor()?
|
||||
} else {
|
||||
self.small_word_range_at_cursor()?
|
||||
};
|
||||
Some(match scope {
|
||||
VimTextObjectScope::Inner => inner,
|
||||
VimTextObjectScope::Around => self.expand_word_around(inner),
|
||||
})
|
||||
}
|
||||
|
||||
fn big_word_range_at_cursor(&self) -> Option<Range<usize>> {
|
||||
self.non_ws_runs()
|
||||
.into_iter()
|
||||
.find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range))
|
||||
}
|
||||
|
||||
fn small_word_range_at_cursor(&self) -> Option<Range<usize>> {
|
||||
for run in self.non_ws_runs() {
|
||||
if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) {
|
||||
continue;
|
||||
}
|
||||
let mut last_piece = None;
|
||||
for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) {
|
||||
let piece = run.start + piece_start..run.start + piece_start + piece.len();
|
||||
if self.cursor_overlaps_range(&piece) {
|
||||
return Some(piece);
|
||||
}
|
||||
last_piece = Some(piece);
|
||||
}
|
||||
if self.cursor_is_at_range_end(&run) {
|
||||
return last_piece.or(Some(run));
|
||||
}
|
||||
return Some(run);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn non_ws_runs(&self) -> Vec<Range<usize>> {
|
||||
let mut runs = Vec::new();
|
||||
let mut start = None;
|
||||
for (idx, ch) in self.text.char_indices() {
|
||||
if ch.is_whitespace() {
|
||||
if let Some(run_start) = start.take() {
|
||||
runs.push(run_start..idx);
|
||||
}
|
||||
} else if start.is_none() {
|
||||
start = Some(idx);
|
||||
}
|
||||
}
|
||||
if let Some(run_start) = start {
|
||||
runs.push(run_start..self.text.len());
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
fn cursor_overlaps_range(&self, range: &Range<usize>) -> bool {
|
||||
range.start <= self.cursor_pos && self.cursor_pos < range.end
|
||||
}
|
||||
|
||||
fn cursor_is_at_range_end(&self, range: &Range<usize>) -> bool {
|
||||
range.start < range.end && self.cursor_pos == range.end
|
||||
}
|
||||
|
||||
fn expand_word_around(&self, inner: Range<usize>) -> Range<usize> {
|
||||
let following = self.following_whitespace_end(inner.end);
|
||||
if following > inner.end {
|
||||
return inner.start..following;
|
||||
}
|
||||
self.preceding_whitespace_start(inner.start)..inner.end
|
||||
}
|
||||
|
||||
fn following_whitespace_end(&self, start: usize) -> usize {
|
||||
let mut end = start;
|
||||
for (offset, ch) in self.text[start..].char_indices() {
|
||||
if !ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
end = start + offset + ch.len_utf8();
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
fn preceding_whitespace_start(&self, end: usize) -> usize {
|
||||
let mut start = end;
|
||||
for (idx, ch) in self.text[..end].char_indices().rev() {
|
||||
if !ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
start = idx;
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
fn paired_text_object_range(
|
||||
&self,
|
||||
scope: VimTextObjectScope,
|
||||
open: char,
|
||||
close: char,
|
||||
) -> Option<Range<usize>> {
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut best: Option<Range<usize>> = None;
|
||||
for (idx, ch) in self.text.char_indices() {
|
||||
if self.is_inside_element(idx) {
|
||||
continue;
|
||||
}
|
||||
if ch == open {
|
||||
stack.push(idx);
|
||||
} else if ch == close {
|
||||
let Some(open_idx) = stack.pop() else {
|
||||
continue;
|
||||
};
|
||||
let close_end = idx + ch.len_utf8();
|
||||
if open_idx <= self.cursor_pos && self.cursor_pos <= idx {
|
||||
let candidate = match scope {
|
||||
VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx,
|
||||
VimTextObjectScope::Around => open_idx..close_end,
|
||||
};
|
||||
if candidate.start <= candidate.end
|
||||
&& best
|
||||
.as_ref()
|
||||
.is_none_or(|current| candidate.len() < current.len())
|
||||
{
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn quoted_text_object_range(
|
||||
&self,
|
||||
scope: VimTextObjectScope,
|
||||
quote: char,
|
||||
) -> Option<Range<usize>> {
|
||||
let line = self.beginning_of_current_line()..self.end_of_current_line();
|
||||
let mut open = None;
|
||||
let mut best: Option<Range<usize>> = None;
|
||||
for (offset, ch) in self.text[line.clone()].char_indices() {
|
||||
let idx = line.start + offset;
|
||||
if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) {
|
||||
continue;
|
||||
}
|
||||
if let Some(open_idx) = open.take() {
|
||||
if open_idx <= self.cursor_pos && self.cursor_pos <= idx {
|
||||
let candidate = match scope {
|
||||
VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx,
|
||||
VimTextObjectScope::Around => idx_range(open_idx, idx, quote),
|
||||
};
|
||||
if candidate.start <= candidate.end
|
||||
&& best
|
||||
.as_ref()
|
||||
.is_none_or(|current| candidate.len() < current.len())
|
||||
{
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
open = Some(idx);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn is_inside_element(&self, pos: usize) -> bool {
|
||||
self.elements
|
||||
.iter()
|
||||
.any(|element| pos >= element.range.start && pos < element.range.end)
|
||||
}
|
||||
|
||||
fn is_escaped(&self, pos: usize) -> bool {
|
||||
let mut backslashes = 0;
|
||||
for ch in self.text[..pos].chars().rev() {
|
||||
if ch != '\\' {
|
||||
break;
|
||||
}
|
||||
backslashes += 1;
|
||||
}
|
||||
backslashes % 2 == 1
|
||||
}
|
||||
}
|
||||
|
||||
fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range<usize> {
|
||||
open_idx..close_idx + quote.len_utf8()
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub(crate) struct RuntimeKeymap {
|
||||
pub(crate) editor: EditorKeymap,
|
||||
pub(crate) vim_normal: VimNormalKeymap,
|
||||
pub(crate) vim_operator: VimOperatorKeymap,
|
||||
pub(crate) vim_text_object: VimTextObjectKeymap,
|
||||
pub(crate) pager: PagerKeymap,
|
||||
pub(crate) list: ListKeymap,
|
||||
pub(crate) approval: ApprovalKeymap,
|
||||
@@ -133,7 +134,7 @@ pub(crate) struct EditorKeymap {
|
||||
///
|
||||
/// Normal mode is the resting state when Vim is enabled. Pressing a movement
|
||||
/// or editing key here either moves the cursor, triggers an operator-pending
|
||||
/// state (via `start_delete_operator` / `start_yank_operator`), or transitions
|
||||
/// state (via an operator-start action), or transitions
|
||||
/// to insert mode. Default bindings include both `shift(letter)` and
|
||||
/// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to
|
||||
/// handle cross-terminal shift-reporting inconsistencies.
|
||||
@@ -156,14 +157,18 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) move_line_end: Vec<KeyBinding>,
|
||||
pub(crate) delete_char: Vec<KeyBinding>,
|
||||
pub(crate) delete_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) change_to_line_end: Vec<KeyBinding>,
|
||||
pub(crate) substitute_char: Vec<KeyBinding>,
|
||||
pub(crate) substitute_line: Vec<KeyBinding>,
|
||||
pub(crate) yank_line: Vec<KeyBinding>,
|
||||
pub(crate) paste_after: Vec<KeyBinding>,
|
||||
pub(crate) start_delete_operator: Vec<KeyBinding>,
|
||||
pub(crate) start_yank_operator: Vec<KeyBinding>,
|
||||
pub(crate) start_change_operator: Vec<KeyBinding>,
|
||||
pub(crate) cancel_operator: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Vim operator-pending keybindings active after `d` or `y` in normal mode.
|
||||
/// Vim operator-pending keybindings active after `d`, `y`, or `c` in normal mode.
|
||||
///
|
||||
/// When an operator (`start_delete_operator` or `start_yank_operator`) is
|
||||
/// pressed, the next keypress is matched against this context to determine the
|
||||
@@ -173,6 +178,7 @@ pub(crate) struct VimNormalKeymap {
|
||||
pub(crate) struct VimOperatorKeymap {
|
||||
pub(crate) delete_line: Vec<KeyBinding>,
|
||||
pub(crate) yank_line: Vec<KeyBinding>,
|
||||
pub(crate) change_line: Vec<KeyBinding>,
|
||||
pub(crate) motion_left: Vec<KeyBinding>,
|
||||
pub(crate) motion_right: Vec<KeyBinding>,
|
||||
pub(crate) motion_up: Vec<KeyBinding>,
|
||||
@@ -182,6 +188,22 @@ pub(crate) struct VimOperatorKeymap {
|
||||
pub(crate) motion_word_end: Vec<KeyBinding>,
|
||||
pub(crate) motion_line_start: Vec<KeyBinding>,
|
||||
pub(crate) motion_line_end: Vec<KeyBinding>,
|
||||
pub(crate) select_inner_text_object: Vec<KeyBinding>,
|
||||
pub(crate) select_around_text_object: Vec<KeyBinding>,
|
||||
pub(crate) cancel: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Vim text-object keybindings active after an operator plus inner/around prefix.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct VimTextObjectKeymap {
|
||||
pub(crate) word: Vec<KeyBinding>,
|
||||
pub(crate) big_word: Vec<KeyBinding>,
|
||||
pub(crate) parentheses: Vec<KeyBinding>,
|
||||
pub(crate) brackets: Vec<KeyBinding>,
|
||||
pub(crate) braces: Vec<KeyBinding>,
|
||||
pub(crate) double_quote: Vec<KeyBinding>,
|
||||
pub(crate) single_quote: Vec<KeyBinding>,
|
||||
pub(crate) backtick: Vec<KeyBinding>,
|
||||
pub(crate) cancel: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
@@ -451,7 +473,7 @@ impl RuntimeKeymap {
|
||||
yank: resolve_local!(keymap, defaults, editor, yank),
|
||||
};
|
||||
|
||||
let vim_normal = VimNormalKeymap {
|
||||
let mut vim_normal = VimNormalKeymap {
|
||||
enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert),
|
||||
append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor),
|
||||
append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end),
|
||||
@@ -469,6 +491,9 @@ impl RuntimeKeymap {
|
||||
move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end),
|
||||
delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char),
|
||||
delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end),
|
||||
change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end),
|
||||
substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char),
|
||||
substitute_line: resolve_local!(keymap, defaults, vim_normal, substitute_line),
|
||||
yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line),
|
||||
paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after),
|
||||
start_delete_operator: resolve_local!(
|
||||
@@ -478,12 +503,138 @@ impl RuntimeKeymap {
|
||||
start_delete_operator
|
||||
),
|
||||
start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator),
|
||||
start_change_operator: resolve_local!(
|
||||
keymap,
|
||||
defaults,
|
||||
vim_normal,
|
||||
start_change_operator
|
||||
),
|
||||
cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator),
|
||||
};
|
||||
|
||||
let vim_operator = VimOperatorKeymap {
|
||||
let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([
|
||||
(
|
||||
keymap.vim_normal.enter_insert.as_ref(),
|
||||
vim_normal.enter_insert.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.append_after_cursor.as_ref(),
|
||||
vim_normal.append_after_cursor.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.append_line_end.as_ref(),
|
||||
vim_normal.append_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.insert_line_start.as_ref(),
|
||||
vim_normal.insert_line_start.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.open_line_below.as_ref(),
|
||||
vim_normal.open_line_below.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.open_line_above.as_ref(),
|
||||
vim_normal.open_line_above.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_left.as_ref(),
|
||||
vim_normal.move_left.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_right.as_ref(),
|
||||
vim_normal.move_right.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_up.as_ref(),
|
||||
vim_normal.move_up.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_down.as_ref(),
|
||||
vim_normal.move_down.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_word_forward.as_ref(),
|
||||
vim_normal.move_word_forward.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_word_backward.as_ref(),
|
||||
vim_normal.move_word_backward.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_word_end.as_ref(),
|
||||
vim_normal.move_word_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_line_start.as_ref(),
|
||||
vim_normal.move_line_start.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.move_line_end.as_ref(),
|
||||
vim_normal.move_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.delete_char.as_ref(),
|
||||
vim_normal.delete_char.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.delete_to_line_end.as_ref(),
|
||||
vim_normal.delete_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.change_to_line_end.as_ref(),
|
||||
vim_normal.change_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.substitute_char.as_ref(),
|
||||
vim_normal.substitute_char.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.substitute_line.as_ref(),
|
||||
vim_normal.substitute_line.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.yank_line.as_ref(),
|
||||
vim_normal.yank_line.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.paste_after.as_ref(),
|
||||
vim_normal.paste_after.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.start_delete_operator.as_ref(),
|
||||
vim_normal.start_delete_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.start_yank_operator.as_ref(),
|
||||
vim_normal.start_yank_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_normal.cancel_operator.as_ref(),
|
||||
vim_normal.cancel_operator.as_slice(),
|
||||
),
|
||||
]);
|
||||
|
||||
if keymap.vim_normal.start_change_operator.is_none() {
|
||||
vim_normal
|
||||
.start_change_operator
|
||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
if keymap.vim_normal.substitute_char.is_none() {
|
||||
vim_normal
|
||||
.substitute_char
|
||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
if keymap.vim_normal.substitute_line.is_none() {
|
||||
vim_normal
|
||||
.substitute_line
|
||||
.retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
|
||||
let mut vim_operator = VimOperatorKeymap {
|
||||
delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line),
|
||||
yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line),
|
||||
change_line: resolve_local!(keymap, defaults, vim_operator, change_line),
|
||||
motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left),
|
||||
motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right),
|
||||
motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up),
|
||||
@@ -503,9 +654,104 @@ impl RuntimeKeymap {
|
||||
motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end),
|
||||
motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start),
|
||||
motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end),
|
||||
select_inner_text_object: resolve_local!(
|
||||
keymap,
|
||||
defaults,
|
||||
vim_operator,
|
||||
select_inner_text_object
|
||||
),
|
||||
select_around_text_object: resolve_local!(
|
||||
keymap,
|
||||
defaults,
|
||||
vim_operator,
|
||||
select_around_text_object
|
||||
),
|
||||
cancel: resolve_local!(keymap, defaults, vim_operator, cancel),
|
||||
};
|
||||
|
||||
let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([
|
||||
(
|
||||
keymap.vim_operator.delete_line.as_ref(),
|
||||
vim_operator.delete_line.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.yank_line.as_ref(),
|
||||
vim_operator.yank_line.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.change_line.as_ref(),
|
||||
vim_operator.change_line.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_left.as_ref(),
|
||||
vim_operator.motion_left.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_right.as_ref(),
|
||||
vim_operator.motion_right.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_up.as_ref(),
|
||||
vim_operator.motion_up.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_down.as_ref(),
|
||||
vim_operator.motion_down.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_word_forward.as_ref(),
|
||||
vim_operator.motion_word_forward.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_word_backward.as_ref(),
|
||||
vim_operator.motion_word_backward.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_word_end.as_ref(),
|
||||
vim_operator.motion_word_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_line_start.as_ref(),
|
||||
vim_operator.motion_line_start.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.motion_line_end.as_ref(),
|
||||
vim_operator.motion_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.vim_operator.cancel.as_ref(),
|
||||
vim_operator.cancel.as_slice(),
|
||||
),
|
||||
]);
|
||||
|
||||
if keymap.vim_operator.select_inner_text_object.is_none() {
|
||||
vim_operator
|
||||
.select_inner_text_object
|
||||
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
if keymap.vim_operator.select_around_text_object.is_none() {
|
||||
vim_operator
|
||||
.select_around_text_object
|
||||
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
if keymap.vim_operator.change_line.is_none() {
|
||||
vim_operator
|
||||
.change_line
|
||||
.retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding));
|
||||
}
|
||||
|
||||
let vim_text_object = VimTextObjectKeymap {
|
||||
word: resolve_local!(keymap, defaults, vim_text_object, word),
|
||||
big_word: resolve_local!(keymap, defaults, vim_text_object, big_word),
|
||||
parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses),
|
||||
brackets: resolve_local!(keymap, defaults, vim_text_object, brackets),
|
||||
braces: resolve_local!(keymap, defaults, vim_text_object, braces),
|
||||
double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote),
|
||||
single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote),
|
||||
backtick: resolve_local!(keymap, defaults, vim_text_object, backtick),
|
||||
cancel: resolve_local!(keymap, defaults, vim_text_object, cancel),
|
||||
};
|
||||
|
||||
let pager = PagerKeymap {
|
||||
scroll_up: resolve_local!(keymap, defaults, pager, scroll_up),
|
||||
scroll_down: resolve_local!(keymap, defaults, pager, scroll_down),
|
||||
@@ -534,8 +780,7 @@ impl RuntimeKeymap {
|
||||
let list_move_down = resolve_local!(keymap, defaults, list, move_down);
|
||||
let list_accept = resolve_local!(keymap, defaults, list, accept);
|
||||
let list_cancel = resolve_local!(keymap, defaults, list, cancel);
|
||||
let mut configured_bindings_to_preserve = Vec::new();
|
||||
for (configured, resolved) in [
|
||||
let configured_bindings_to_preserve = configured_bindings_to_preserve([
|
||||
(
|
||||
keymap.global.open_transcript.as_ref(),
|
||||
app.open_transcript.as_slice(),
|
||||
@@ -591,51 +836,42 @@ impl RuntimeKeymap {
|
||||
approval.decline.as_slice(),
|
||||
),
|
||||
(keymap.approval.cancel.as_ref(), approval.cancel.as_slice()),
|
||||
] {
|
||||
if configured.is_none() {
|
||||
continue;
|
||||
}
|
||||
for binding in resolved {
|
||||
if !configured_bindings_to_preserve.contains(binding) {
|
||||
configured_bindings_to_preserve.push(*binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
let list = ListKeymap {
|
||||
move_up: list_move_up,
|
||||
move_down: list_move_down,
|
||||
move_left: resolve_new_list_bindings(
|
||||
move_left: resolve_new_default_bindings(
|
||||
keymap.list.move_left.as_ref(),
|
||||
&defaults.list.move_left,
|
||||
&configured_bindings_to_preserve,
|
||||
"tui.keymap.list.move_left",
|
||||
)?,
|
||||
move_right: resolve_new_list_bindings(
|
||||
move_right: resolve_new_default_bindings(
|
||||
keymap.list.move_right.as_ref(),
|
||||
&defaults.list.move_right,
|
||||
&configured_bindings_to_preserve,
|
||||
"tui.keymap.list.move_right",
|
||||
)?,
|
||||
page_up: resolve_new_list_bindings(
|
||||
page_up: resolve_new_default_bindings(
|
||||
keymap.list.page_up.as_ref(),
|
||||
&defaults.list.page_up,
|
||||
&configured_bindings_to_preserve,
|
||||
"tui.keymap.list.page_up",
|
||||
)?,
|
||||
page_down: resolve_new_list_bindings(
|
||||
page_down: resolve_new_default_bindings(
|
||||
keymap.list.page_down.as_ref(),
|
||||
&defaults.list.page_down,
|
||||
&configured_bindings_to_preserve,
|
||||
"tui.keymap.list.page_down",
|
||||
)?,
|
||||
jump_top: resolve_new_list_bindings(
|
||||
jump_top: resolve_new_default_bindings(
|
||||
keymap.list.jump_top.as_ref(),
|
||||
&defaults.list.jump_top,
|
||||
&configured_bindings_to_preserve,
|
||||
"tui.keymap.list.jump_top",
|
||||
)?,
|
||||
jump_bottom: resolve_new_list_bindings(
|
||||
jump_bottom: resolve_new_default_bindings(
|
||||
keymap.list.jump_bottom.as_ref(),
|
||||
&defaults.list.jump_bottom,
|
||||
&configured_bindings_to_preserve,
|
||||
@@ -652,6 +888,7 @@ impl RuntimeKeymap {
|
||||
editor,
|
||||
vim_normal,
|
||||
vim_operator,
|
||||
vim_text_object,
|
||||
pager,
|
||||
list,
|
||||
approval,
|
||||
@@ -786,15 +1023,26 @@ impl RuntimeKeymap {
|
||||
shift(KeyCode::Char('d')),
|
||||
plain(KeyCode::Char('D'))
|
||||
],
|
||||
change_to_line_end: default_bindings![
|
||||
shift(KeyCode::Char('c')),
|
||||
plain(KeyCode::Char('C'))
|
||||
],
|
||||
substitute_char: default_bindings![plain(KeyCode::Char('s'))],
|
||||
substitute_line: default_bindings![
|
||||
shift(KeyCode::Char('s')),
|
||||
plain(KeyCode::Char('S'))
|
||||
],
|
||||
yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))],
|
||||
paste_after: default_bindings![plain(KeyCode::Char('p'))],
|
||||
start_delete_operator: default_bindings![plain(KeyCode::Char('d'))],
|
||||
start_yank_operator: default_bindings![plain(KeyCode::Char('y'))],
|
||||
start_change_operator: default_bindings![plain(KeyCode::Char('c'))],
|
||||
cancel_operator: default_bindings![plain(KeyCode::Esc)],
|
||||
},
|
||||
vim_operator: VimOperatorKeymap {
|
||||
delete_line: default_bindings![plain(KeyCode::Char('d'))],
|
||||
yank_line: default_bindings![plain(KeyCode::Char('y'))],
|
||||
change_line: default_bindings![plain(KeyCode::Char('c'))],
|
||||
motion_left: default_bindings![plain(KeyCode::Char('h'))],
|
||||
motion_right: default_bindings![plain(KeyCode::Char('l'))],
|
||||
motion_up: default_bindings![plain(KeyCode::Char('k'))],
|
||||
@@ -807,6 +1055,35 @@ impl RuntimeKeymap {
|
||||
plain(KeyCode::Char('$')),
|
||||
shift(KeyCode::Char('$'))
|
||||
],
|
||||
select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))],
|
||||
select_around_text_object: default_bindings![plain(KeyCode::Char('a'))],
|
||||
cancel: default_bindings![plain(KeyCode::Esc)],
|
||||
},
|
||||
vim_text_object: VimTextObjectKeymap {
|
||||
word: default_bindings![plain(KeyCode::Char('w'))],
|
||||
big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))],
|
||||
parentheses: default_bindings![
|
||||
plain(KeyCode::Char('(')),
|
||||
shift(KeyCode::Char('(')),
|
||||
plain(KeyCode::Char(')')),
|
||||
shift(KeyCode::Char(')')),
|
||||
plain(KeyCode::Char('b'))
|
||||
],
|
||||
brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))],
|
||||
braces: default_bindings![
|
||||
plain(KeyCode::Char('{')),
|
||||
shift(KeyCode::Char('{')),
|
||||
plain(KeyCode::Char('}')),
|
||||
shift(KeyCode::Char('}')),
|
||||
shift(KeyCode::Char('b')),
|
||||
plain(KeyCode::Char('B'))
|
||||
],
|
||||
double_quote: default_bindings![
|
||||
plain(KeyCode::Char('"')),
|
||||
shift(KeyCode::Char('"'))
|
||||
],
|
||||
single_quote: default_bindings![plain(KeyCode::Char('\''))],
|
||||
backtick: default_bindings![plain(KeyCode::Char('`'))],
|
||||
cancel: default_bindings![plain(KeyCode::Esc)],
|
||||
},
|
||||
pager: PagerKeymap {
|
||||
@@ -1176,6 +1453,18 @@ impl RuntimeKeymap {
|
||||
"delete_to_line_end",
|
||||
self.vim_normal.delete_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
"change_to_line_end",
|
||||
self.vim_normal.change_to_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
"substitute_char",
|
||||
self.vim_normal.substitute_char.as_slice(),
|
||||
),
|
||||
(
|
||||
"substitute_line",
|
||||
self.vim_normal.substitute_line.as_slice(),
|
||||
),
|
||||
("yank_line", self.vim_normal.yank_line.as_slice()),
|
||||
("paste_after", self.vim_normal.paste_after.as_slice()),
|
||||
(
|
||||
@@ -1186,6 +1475,10 @@ impl RuntimeKeymap {
|
||||
"start_yank_operator",
|
||||
self.vim_normal.start_yank_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
"start_change_operator",
|
||||
self.vim_normal.start_change_operator.as_slice(),
|
||||
),
|
||||
(
|
||||
"cancel_operator",
|
||||
self.vim_normal.cancel_operator.as_slice(),
|
||||
@@ -1198,6 +1491,7 @@ impl RuntimeKeymap {
|
||||
[
|
||||
("delete_line", self.vim_operator.delete_line.as_slice()),
|
||||
("yank_line", self.vim_operator.yank_line.as_slice()),
|
||||
("change_line", self.vim_operator.change_line.as_slice()),
|
||||
("motion_left", self.vim_operator.motion_left.as_slice()),
|
||||
("motion_right", self.vim_operator.motion_right.as_slice()),
|
||||
("motion_up", self.vim_operator.motion_up.as_slice()),
|
||||
@@ -1222,10 +1516,33 @@ impl RuntimeKeymap {
|
||||
"motion_line_end",
|
||||
self.vim_operator.motion_line_end.as_slice(),
|
||||
),
|
||||
(
|
||||
"select_inner_text_object",
|
||||
self.vim_operator.select_inner_text_object.as_slice(),
|
||||
),
|
||||
(
|
||||
"select_around_text_object",
|
||||
self.vim_operator.select_around_text_object.as_slice(),
|
||||
),
|
||||
("cancel", self.vim_operator.cancel.as_slice()),
|
||||
],
|
||||
)?;
|
||||
|
||||
validate_unique(
|
||||
"vim_text_object",
|
||||
[
|
||||
("word", self.vim_text_object.word.as_slice()),
|
||||
("big_word", self.vim_text_object.big_word.as_slice()),
|
||||
("parentheses", self.vim_text_object.parentheses.as_slice()),
|
||||
("brackets", self.vim_text_object.brackets.as_slice()),
|
||||
("braces", self.vim_text_object.braces.as_slice()),
|
||||
("double_quote", self.vim_text_object.double_quote.as_slice()),
|
||||
("single_quote", self.vim_text_object.single_quote.as_slice()),
|
||||
("backtick", self.vim_text_object.backtick.as_slice()),
|
||||
("cancel", self.vim_text_object.cancel.as_slice()),
|
||||
],
|
||||
)?;
|
||||
|
||||
validate_unique(
|
||||
"pager",
|
||||
[
|
||||
@@ -1514,7 +1831,24 @@ fn resolve_bindings(
|
||||
parse_bindings(spec, path)
|
||||
}
|
||||
|
||||
fn resolve_new_list_bindings(
|
||||
fn configured_bindings_to_preserve<const N: usize>(
|
||||
pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N],
|
||||
) -> Vec<KeyBinding> {
|
||||
let mut configured_bindings = Vec::new();
|
||||
for (configured, resolved) in pairs {
|
||||
if configured.is_none() {
|
||||
continue;
|
||||
}
|
||||
for binding in resolved {
|
||||
if !configured_bindings.contains(binding) {
|
||||
configured_bindings.push(*binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
configured_bindings
|
||||
}
|
||||
|
||||
fn resolve_new_default_bindings(
|
||||
configured: Option<&KeybindingsSpec>,
|
||||
fallback: &[KeyBinding],
|
||||
configured_bindings_to_preserve: &[KeyBinding],
|
||||
@@ -1963,6 +2297,75 @@ mod tests {
|
||||
expect_conflict(&keymap, "list.jump_top", "approval.approve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("c"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
runtime.vim_normal.move_left,
|
||||
vec![key_hint::plain(KeyCode::Char('c'))]
|
||||
);
|
||||
assert_eq!(runtime.vim_normal.start_change_operator, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("c"));
|
||||
keymap.vim_normal.start_change_operator = Some(one("c"));
|
||||
|
||||
expect_conflict(&keymap, "move_left", "start_change_operator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_bindings_prune_new_substitute_and_change_line_defaults() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_normal.move_left = Some(one("s"));
|
||||
keymap.vim_normal.move_right = Some(one("shift-s"));
|
||||
keymap.vim_operator.motion_left = Some(one("c"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(runtime.vim_normal.substitute_char, Vec::new());
|
||||
assert_eq!(
|
||||
runtime.vim_normal.substitute_line,
|
||||
vec![key_hint::plain(KeyCode::Char('S'))]
|
||||
);
|
||||
assert_eq!(runtime.vim_operator.change_line, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_operator.motion_left = Some(one("i"));
|
||||
keymap.vim_operator.motion_right = Some(one("a"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
|
||||
|
||||
assert_eq!(
|
||||
runtime.vim_operator.motion_left,
|
||||
vec![key_hint::plain(KeyCode::Char('i'))]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.vim_operator.motion_right,
|
||||
vec![key_hint::plain(KeyCode::Char('a'))]
|
||||
);
|
||||
assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new());
|
||||
assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.vim_operator.motion_left = Some(one("i"));
|
||||
keymap.vim_operator.select_inner_text_object = Some(one("i"));
|
||||
|
||||
expect_conflict(&keymap, "motion_left", "select_inner_text_object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_normal_defaults_include_insert_and_arrow_aliases() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
|
||||
@@ -135,13 +135,18 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."),
|
||||
action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."),
|
||||
action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."),
|
||||
action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "substitute_char", "Substitute the character under the cursor and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "substitute_line", "Substitute the current line and enter insert mode."),
|
||||
action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."),
|
||||
action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."),
|
||||
action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."),
|
||||
action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a motion or text object."),
|
||||
action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."),
|
||||
action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."),
|
||||
action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."),
|
||||
action("vim_operator", "Vim operator", "change_line", "Repeat change operator to change the whole line."),
|
||||
action("vim_operator", "Vim operator", "motion_left", "Operator motion left."),
|
||||
action("vim_operator", "Vim operator", "motion_right", "Operator motion right."),
|
||||
action("vim_operator", "Vim operator", "motion_up", "Operator motion up."),
|
||||
@@ -151,7 +156,18 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."),
|
||||
action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."),
|
||||
action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."),
|
||||
action("vim_operator", "Vim operator", "select_inner_text_object", "Select an inner text object."),
|
||||
action("vim_operator", "Vim operator", "select_around_text_object", "Select an around text object."),
|
||||
action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."),
|
||||
action("vim_text_object", "Vim text object", "word", "Target the current word."),
|
||||
action("vim_text_object", "Vim text object", "big_word", "Target the current WORD."),
|
||||
action("vim_text_object", "Vim text object", "parentheses", "Target enclosing parentheses."),
|
||||
action("vim_text_object", "Vim text object", "brackets", "Target enclosing brackets."),
|
||||
action("vim_text_object", "Vim text object", "braces", "Target enclosing braces."),
|
||||
action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."),
|
||||
action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."),
|
||||
action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."),
|
||||
action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."),
|
||||
action("pager", "Pager", "scroll_up", "Scroll up by one row."),
|
||||
action("pager", "Pager", "scroll_down", "Scroll down by one row."),
|
||||
action("pager", "Pager", "page_up", "Scroll up by one page."),
|
||||
@@ -263,13 +279,18 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end),
|
||||
("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char),
|
||||
("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end),
|
||||
("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end),
|
||||
("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char),
|
||||
("vim_normal", "substitute_line") => Some(&mut keymap.vim_normal.substitute_line),
|
||||
("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line),
|
||||
("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after),
|
||||
("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator),
|
||||
("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator),
|
||||
("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator),
|
||||
("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator),
|
||||
("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line),
|
||||
("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line),
|
||||
("vim_operator", "change_line") => Some(&mut keymap.vim_operator.change_line),
|
||||
("vim_operator", "motion_left") => Some(&mut keymap.vim_operator.motion_left),
|
||||
("vim_operator", "motion_right") => Some(&mut keymap.vim_operator.motion_right),
|
||||
("vim_operator", "motion_up") => Some(&mut keymap.vim_operator.motion_up),
|
||||
@@ -279,7 +300,18 @@ pub(super) fn binding_slot<'a>(
|
||||
("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end),
|
||||
("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start),
|
||||
("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end),
|
||||
("vim_operator", "select_inner_text_object") => Some(&mut keymap.vim_operator.select_inner_text_object),
|
||||
("vim_operator", "select_around_text_object") => Some(&mut keymap.vim_operator.select_around_text_object),
|
||||
("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel),
|
||||
("vim_text_object", "word") => Some(&mut keymap.vim_text_object.word),
|
||||
("vim_text_object", "big_word") => Some(&mut keymap.vim_text_object.big_word),
|
||||
("vim_text_object", "parentheses") => Some(&mut keymap.vim_text_object.parentheses),
|
||||
("vim_text_object", "brackets") => Some(&mut keymap.vim_text_object.brackets),
|
||||
("vim_text_object", "braces") => Some(&mut keymap.vim_text_object.braces),
|
||||
("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote),
|
||||
("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote),
|
||||
("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick),
|
||||
("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel),
|
||||
("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up),
|
||||
("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down),
|
||||
("pager", "page_up") => Some(&mut keymap.pager.page_up),
|
||||
@@ -373,13 +405,18 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
|
||||
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
|
||||
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
|
||||
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
|
||||
("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()),
|
||||
("vim_normal", "substitute_line") => Some(runtime_keymap.vim_normal.substitute_line.as_slice()),
|
||||
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
|
||||
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
|
||||
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
|
||||
("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()),
|
||||
("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()),
|
||||
("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()),
|
||||
("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()),
|
||||
("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()),
|
||||
("vim_operator", "change_line") => Some(runtime_keymap.vim_operator.change_line.as_slice()),
|
||||
("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()),
|
||||
("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()),
|
||||
("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()),
|
||||
@@ -389,7 +426,18 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()),
|
||||
("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()),
|
||||
("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()),
|
||||
("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()),
|
||||
("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()),
|
||||
("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()),
|
||||
("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()),
|
||||
("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()),
|
||||
("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()),
|
||||
("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()),
|
||||
("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()),
|
||||
("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()),
|
||||
("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()),
|
||||
("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()),
|
||||
("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()),
|
||||
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
|
||||
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
|
||||
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
|
||||
|
||||
@@ -104,7 +104,7 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[
|
||||
id: "vim-shortcuts",
|
||||
label: "Vim",
|
||||
description: "Vim normal-mode and operator shortcuts.",
|
||||
contexts: &["vim_normal", "vim_operator"],
|
||||
contexts: &["vim_normal", "vim_operator", "vim_text_object"],
|
||||
},
|
||||
KeymapContextTab {
|
||||
id: "navigation-shortcuts",
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 1 customized, 2 unbound.
|
||||
109 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
94 actions, 0 customized, 3 unbound.
|
||||
110 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (93 selectable)
|
||||
tab: All (109 selectable)
|
||||
tab: Common (19 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (9 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (34 selectable)
|
||||
tab: Vim (50 selectable)
|
||||
tab: Navigation (20 selectable)
|
||||
tab: Approval (8 selectable)
|
||||
tab: Debug (1 selectable)
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 0 customized, 2 unbound.
|
||||
109 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
93 actions, 0 customized, 2 unbound.
|
||||
109 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
|
||||
Reference in New Issue
Block a user