mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks (#637)
* feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks feat(shell): give Nushell panes OSC 7 cwd tracking and OSC 133 marks Nushell was the only detected shell with no integration: shell_kind never matched `nu`, so panes were spawned with no OSC 7 cwd reports and the daemon could not follow `cd` — pwsh, zsh, bash and fish all do. Recent Nushells emit their own OSC 133 marks and a Windows `OSC 9;9` cwd protocol, but tty7 only consumes OSC 7, and the native `shell_integration.osc7` toggle defaults to off. The injection rides `nu --config` (Nushell has no ZDOTDIR analogue): a throwaway config.nu sources the user's own config.nu back in first, then appends hooks. `source` is a parse-time construct in Nushell — it cannot be guarded at runtime or name a missing file — so the Rust side resolves the path with the same rules `$nu.default-config-dir` uses (APPDATA / XDG / HOME) and substitutes a literal, or a no-op line when there is no config.nu. The hooks report cwd (OSC 7, %-escaped, `/C:/…` shape on Windows), prompt start (A) and the previous command's exit (D, gated on a flag the pre_execution hook arms so the first prompt emits nothing), mark command output (C), and wrap prompt_indicator for the prompt-end mark (B) only when the config defines one — recent Nushells' built-in prompt keeps drawing its own indicator and B mark, and overlapping marks merge in the daemon's prompt-state machine. Remote SSH and WSL panes are unchanged: their bootstraps cannot carry a Nushell config, and a nu-as-login-shell session would break on one. Tests: static assertions on the script, setup dispatch, literal substitution, and a Windows real-PTY cycle asserting OSC 7 follows `cd` and every mark arrives with the correct exit status. fmt * fix(shell): resolve the Nushell config dir the way nu does The wrapper's `--config` replaces the user's config.nu entirely, so the path it sources back must be the one nu itself would load — anything else silently strips macOS panes of their prompt, aliases and keybindings. `dirs::config_dir()` is `~/Library/Application Support` on macOS, not `~/.config`, and nu-path consults `$XDG_CONFIG_HOME` on every platform (Windows included) but only when it is non-empty and absolute. The resolution now mirrors that: a per-platform default plus XDG winning only in the exact shape nu accepts. Also gate the OSC 7 backslash translation on Windows (`$nu.os-info.name`) so a Unix path with a literal backslash survives, and harden the real-PTY tests: one line per submitted command (reedline drops input while a command runs) and a grace period after the D mark so the cwd report that follows it in the same prompt cycle lands in the transcript.
This commit is contained in:
@@ -517,6 +517,11 @@ pub fn git_bash_path() -> Option<PathBuf> {
|
||||
find_git_bash()
|
||||
}
|
||||
|
||||
#[cfg(all(windows, test))]
|
||||
pub fn nushell_path() -> Option<PathBuf> {
|
||||
find_in_path("nu.exe")
|
||||
}
|
||||
|
||||
#[cfg(all(windows, test))]
|
||||
mod wsl_tests {
|
||||
#[test]
|
||||
|
||||
@@ -618,6 +618,82 @@ if (-not $env:TTY7_SHELL_INTEGRATION) {
|
||||
# --- end tty7 shell integration ---
|
||||
"#;
|
||||
|
||||
const NUSHELL_INTEGRATION: &str = r#"
|
||||
# --- tty7 shell integration (nushell) ---
|
||||
# Guard on emptiness (`== ""`), not mere definedness, exactly like the other
|
||||
# shells: setup() resets the sentinel to an empty-but-exported value at each
|
||||
# spawn boundary, and a nested `nu` inherits the set `1` without re-installing.
|
||||
if (($env.TTY7_SHELL_INTEGRATION? | default "") == "") {
|
||||
$env.TTY7_SHELL_INTEGRATION = "1"
|
||||
|
||||
# tty7 restores the user's own config.nu at this placeholder — `source` is
|
||||
# evaluated at parse time, so the wrapper can only name a file that provably
|
||||
# exists; the Rust side resolves the path (the same resolution
|
||||
# `$nu.default-config-dir` performs) and substitutes a literal, or a no-op
|
||||
# line when there is no config.nu. The order is load-bearing: a config.nu
|
||||
# conventionally ends with a wholesale `$env.config = {...}` that would
|
||||
# clobber every hook added below, so the hooks must come after it.
|
||||
__TTY7_SOURCE_USER_CONFIG__
|
||||
|
||||
# Hooks live in `$env.config.hooks` as lists. Ensure the skeleton exists
|
||||
# before appending to it — a minimal config, or none at all, leaves it
|
||||
# missing, and a bare assignment would error instead of creating it.
|
||||
if (($env.config.hooks? | default {}) | is-empty) {
|
||||
$env.config = ($env.config | upsert hooks { pre_prompt: [], pre_execution: [], env_change: {} })
|
||||
}
|
||||
|
||||
$env.config.hooks.pre_prompt = ($env.config.hooks.pre_prompt | default [] | append {||
|
||||
# OSC 133 prompt-start (A) and the previous command's exit (D). D is gated
|
||||
# on a flag the pre_execution hook arms, so the very first prompt emits no
|
||||
# bogus exit status — the same rule every other integration follows.
|
||||
let __tty7_osc = {|__tty7_payload| print -n $"(ansi osc)($__tty7_payload)(char bel)" }
|
||||
if ($env.__tty7_cmd_active? | default false) {
|
||||
hide-env __tty7_cmd_active
|
||||
do $__tty7_osc $"133;D;($env.LAST_EXIT_CODE? | default 0)"
|
||||
}
|
||||
# OSC 7 cwd, so the daemon tracks the pane's directory across `cd`. The
|
||||
# payload is percent-decoded on the daemon side, so a literal `%` must be
|
||||
# escaped as %25, and a Windows drive path needs the leading slash that
|
||||
# makes `C:/…` an absolute URI path (`/C:/…`). Backslashes are separators
|
||||
# only on Windows — a Unix path may legally contain one, so that
|
||||
# translation is gated on the platform the way nu's own OSC 7 gates it.
|
||||
let __tty7_path = if (($nu.os-info.name? | default '') == 'windows') {
|
||||
($env.PWD | str replace -a '\' '/' | str replace -a '%' '%25')
|
||||
} else {
|
||||
($env.PWD | str replace -a '%' '%25')
|
||||
}
|
||||
let __tty7_path = if ($__tty7_path | str starts-with '/') { $__tty7_path } else { '/' + $__tty7_path }
|
||||
do $__tty7_osc $"7;file://($env.COMPUTERNAME? | default 'localhost')($__tty7_path)"
|
||||
do $__tty7_osc "133;A"
|
||||
})
|
||||
|
||||
# OSC 133 command-output-begins (C) plus the flag that gates the D report.
|
||||
# Nushell's pre_execution hook receives no command text, so C carries no
|
||||
# payload — the daemon still flips to "command running" on the bare mark.
|
||||
$env.config.hooks.pre_execution = ($env.config.hooks.pre_execution | default [] | append {||
|
||||
$env.__tty7_cmd_active = true
|
||||
print -n $"(ansi osc)133;C(char bel)"
|
||||
})
|
||||
|
||||
# OSC 133 prompt-end (B) must land after the very last prompt character, and
|
||||
# the only hook-shaped place Nushell runs at that point is prompt_indicator —
|
||||
# so wrap it, but only when the config defines one. A missing indicator is
|
||||
# the built-in default prompt's to render (recent Nushells draw it and their
|
||||
# own B mark themselves), and replacing it here would erase the glyph.
|
||||
if ((($env.config.prompt_indicator? | default null) | describe) != 'nothing') {
|
||||
let __tty7_orig_indicator = $env.config.prompt_indicator
|
||||
$env.config.prompt_indicator = {||
|
||||
let __tty7_ind = match ($__tty7_orig_indicator | describe) {
|
||||
'closure' => (do $__tty7_orig_indicator)
|
||||
_ => $__tty7_orig_indicator
|
||||
}
|
||||
$"($__tty7_ind)(ansi osc)133;B(char bel)"
|
||||
}
|
||||
}
|
||||
}
|
||||
# --- end tty7 shell integration ---
|
||||
"#;
|
||||
|
||||
fn zsh_redirectors() -> [(&'static str, String); 4] {
|
||||
let redirect = |name: &str, tail: &str| {
|
||||
format!(
|
||||
@@ -674,6 +750,7 @@ enum ShellKind {
|
||||
Bash,
|
||||
Fish,
|
||||
PowerShell,
|
||||
Nushell,
|
||||
Wsl,
|
||||
}
|
||||
|
||||
@@ -692,6 +769,7 @@ fn shell_kind(program: Option<&str>) -> Option<ShellKind> {
|
||||
"bash" => Some(ShellKind::Bash),
|
||||
"fish" => Some(ShellKind::Fish),
|
||||
"powershell" | "pwsh" => Some(ShellKind::PowerShell),
|
||||
"nu" => Some(ShellKind::Nushell),
|
||||
"wsl" => Some(ShellKind::Wsl),
|
||||
_ => None,
|
||||
}
|
||||
@@ -796,6 +874,121 @@ fn setup_powershell() -> Option<Injection> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Nushell has no environment variable that redirects its config file the way
|
||||
/// ZDOTDIR does for zsh, so the injection rides `--config` instead: a
|
||||
/// throwaway config.nu that sources the user's real one and then appends the
|
||||
/// OSC hooks. The `dir` is what makes the file disappear when the pane closes.
|
||||
///
|
||||
/// Trade-off, inherent to `--config`: `$nu.config-path` points at the wrapper,
|
||||
/// so `config nu` inside the pane edits a file that vanishes with it. The zsh
|
||||
/// path dodges the analogue by restoring ZDOTDIR after startup; Nushell's
|
||||
/// `$nu.*` paths are immutable, so this is accepted rather than fought.
|
||||
fn setup_nushell() -> Option<Injection> {
|
||||
setup_nushell_with(nushell_user_config_path().as_deref())
|
||||
}
|
||||
|
||||
/// Split from `setup_nushell` so a test can drive the whole wrapper against a
|
||||
/// config.nu of its own without writing into the real one.
|
||||
fn setup_nushell_with(user_config: Option<&Path>) -> Option<Injection> {
|
||||
let dir = throwaway_dir("tty7-nu-")?;
|
||||
let config = dir.join("config.nu");
|
||||
std::fs::write(&config, nushell_config_script_with(user_config)).ok()?;
|
||||
|
||||
Some(Injection {
|
||||
env: HashMap::new(),
|
||||
args: vec![
|
||||
"--config".to_string(),
|
||||
config.to_string_lossy().into_owned(),
|
||||
],
|
||||
replaces_argv: false,
|
||||
dir: Some(dir),
|
||||
})
|
||||
}
|
||||
|
||||
/// The wrapper config.nu: the user's real config.nu sourced back in when it
|
||||
/// exists, then the OSC hooks. `source` is a parse-time construct in Nushell —
|
||||
/// it cannot be guarded at runtime or name a missing file — so the path is
|
||||
/// resolved and substituted here, and a machine without a config.nu gets a
|
||||
/// no-op line instead.
|
||||
fn nushell_config_script_with(user_config: Option<&Path>) -> String {
|
||||
let source = match user_config {
|
||||
Some(path) => format!("source {}", nu_string_literal(&path.to_string_lossy())),
|
||||
None => "# no user config.nu to restore".to_string(),
|
||||
};
|
||||
NUSHELL_INTEGRATION.replace("__TTY7_SOURCE_USER_CONFIG__", &source)
|
||||
}
|
||||
|
||||
/// Where nu would load its config.nu from. Only a file that exists comes
|
||||
/// back — the wrapper's `source` cannot name one that is not there.
|
||||
fn nushell_user_config_path() -> Option<PathBuf> {
|
||||
let config = nushell_config_dir()?.join("config.nu");
|
||||
config.is_file().then_some(config)
|
||||
}
|
||||
|
||||
/// nu's own config directory, resolved the way nu resolves it. Getting this
|
||||
/// wrong is silent and total: the wrapper reports "no config.nu to restore"
|
||||
/// for a user who has one, and `nu --config` then replaces their config with
|
||||
/// one that never sources it.
|
||||
///
|
||||
/// nu-path's `configurable_dir_path` consults `$XDG_CONFIG_HOME` on *every*
|
||||
/// platform, Windows included, and only when it is non-empty **and** absolute;
|
||||
/// anything else falls through to `dirs::config_dir()`.
|
||||
fn nushell_config_dir() -> Option<PathBuf> {
|
||||
nushell_config_dir_from(
|
||||
std::env::var_os("XDG_CONFIG_HOME").as_deref(),
|
||||
platform_config_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
fn nushell_config_dir_from(
|
||||
xdg_config_home: Option<&std::ffi::OsStr>,
|
||||
platform_default: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
let base = match xdg_config_home {
|
||||
Some(xdg) if !xdg.is_empty() && Path::new(xdg).is_absolute() => PathBuf::from(xdg),
|
||||
_ => platform_default?,
|
||||
};
|
||||
Some(base.join("nushell"))
|
||||
}
|
||||
|
||||
/// `dirs::config_dir()` — what nu falls back to. Note this is **not** where
|
||||
/// tty7 keeps its own config (`~/.config/tty7` on macOS too); nu follows the
|
||||
/// platform convention, and the two are only the same directory on Linux.
|
||||
#[cfg(windows)]
|
||||
fn platform_config_dir() -> Option<PathBuf> {
|
||||
// `dirs` asks Windows for FOLDERID_RoamingAppData; `%APPDATA%` is the
|
||||
// same directory in every environment tty7 can spawn a pane in.
|
||||
std::env::var_os("APPDATA")
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_config_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
|
||||
Some(PathBuf::from(home).join("Library/Application Support"))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn platform_config_dir() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
|
||||
Some(PathBuf::from(home).join(".config"))
|
||||
}
|
||||
|
||||
/// A Nushell string literal for `path`. Single quotes are fully literal in
|
||||
/// Nushell, so they are the default; a path that contains an apostrophe (legal
|
||||
/// on Windows) falls back to double quotes with backslash escaping.
|
||||
fn nu_string_literal(path: &str) -> String {
|
||||
if !path.contains('\'') {
|
||||
return format!("'{path}'");
|
||||
}
|
||||
let escaped = path
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$");
|
||||
format!("\"{escaped}\"")
|
||||
}
|
||||
|
||||
fn powershell_encoded_command(script: &str) -> String {
|
||||
let utf16le: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
base64_encode(&utf16le)
|
||||
@@ -1062,6 +1255,7 @@ pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> O
|
||||
ShellKind::Fish => setup_fish(),
|
||||
ShellKind::Bash => setup_bash(),
|
||||
ShellKind::PowerShell => setup_powershell(),
|
||||
ShellKind::Nushell => setup_nushell(),
|
||||
#[cfg(windows)]
|
||||
ShellKind::Wsl => setup_wsl(args),
|
||||
#[cfg(not(windows))]
|
||||
@@ -1457,6 +1651,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
let mut answered = 0usize;
|
||||
let mut typed = false;
|
||||
let mut seen_fail = None;
|
||||
while std::time::Instant::now() < deadline {
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
|
||||
Ok(chunk) => out.extend_from_slice(&chunk),
|
||||
@@ -1481,13 +1676,15 @@ mod tests {
|
||||
answered = asked;
|
||||
}
|
||||
|
||||
// Type only once the first prompt is fully drawn (B is its last act).
|
||||
// Type only once the first prompt is marked (A or B — they arrive
|
||||
// in the same prompt cycle for every integrated shell, and a
|
||||
// Nushell without a config-defined prompt_indicator emits only A).
|
||||
// Writing at spawn time instead puts the keystrokes ahead of the
|
||||
// cursor-position reply in the same input stream, and pwsh — still
|
||||
// waiting on that reply — eats `false\r` as the answer to its own
|
||||
// query. The command then never runs, and the failure reads as a
|
||||
// missing C mark rather than as the race it is.
|
||||
if !typed && text.contains("133;B") {
|
||||
if !typed && (text.contains("133;A") || text.contains("133;B")) {
|
||||
writer.write_all(keys).expect("write");
|
||||
writer.flush().expect("flush");
|
||||
typed = true;
|
||||
@@ -1497,8 +1694,17 @@ mod tests {
|
||||
// emit an unpaired D while drawing its first prompt, and breaking on
|
||||
// that would sample the transcript before the prompt cycle under test
|
||||
// has run at all.
|
||||
if typed && text.contains(FAILED_COMMAND_MARK) {
|
||||
break;
|
||||
if typed && text.contains(FAILED_COMMAND_MARK) && seen_fail.is_none() {
|
||||
seen_fail = Some(std::time::Instant::now());
|
||||
}
|
||||
// The D mark and the rest of the same prompt cycle (the cwd report,
|
||||
// the A mark) are separate writes: breaking the moment the mark
|
||||
// arrives can sample the transcript before the report does. Hold
|
||||
// the pty open briefly so the cycle's tail lands.
|
||||
if let Some(at) = seen_fail {
|
||||
if at.elapsed() >= std::time::Duration::from_millis(500) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reap before asserting: a panic here would otherwise leave the shell
|
||||
@@ -1508,7 +1714,7 @@ mod tests {
|
||||
drop(pty.master);
|
||||
assert!(
|
||||
typed,
|
||||
"no prompt-end mark ever arrived, so nothing was typed; got:\n{}",
|
||||
"no prompt-start mark ever arrived, so nothing was typed; got:\n{}",
|
||||
String::from_utf8_lossy(&out)
|
||||
);
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
@@ -1524,6 +1730,19 @@ mod tests {
|
||||
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"))
|
||||
}
|
||||
|
||||
/// The last OSC 7 cwd report in a transcript — the one emitted after the
|
||||
/// typed commands ran, which is what proves `cd` moved the pane's cwd.
|
||||
fn last_osc7(text: &str) -> PathBuf {
|
||||
let payload = text
|
||||
.split("\u{1b}]")
|
||||
.filter(|s| s.starts_with("7;file://"))
|
||||
.last()
|
||||
.and_then(|s| s.split(['\u{7}', '\u{1b}']).next())
|
||||
.unwrap_or_else(|| panic!("expected OSC 7; got:\n{text}"));
|
||||
crate::daemon::pane::parse_osc7(payload.as_bytes())
|
||||
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() {
|
||||
@@ -1682,6 +1901,12 @@ mod tests {
|
||||
"{prog} should map to PowerShell"
|
||||
);
|
||||
}
|
||||
for prog in ["nu", "nu.exe", "C:/Tools/nu.exe"] {
|
||||
assert!(
|
||||
matches!(shell_kind(Some(prog)), Some(ShellKind::Nushell)),
|
||||
"{prog} should map to Nushell"
|
||||
);
|
||||
}
|
||||
assert!(shell_kind(Some("/bin/sh")).is_none());
|
||||
assert!(shell_kind(Some("cmd.exe")).is_none());
|
||||
assert!(matches!(shell_kind(Some("wsl.exe")), Some(ShellKind::Wsl)));
|
||||
@@ -2349,6 +2574,16 @@ mod tests {
|
||||
|
||||
assert!(setup(Some("pwsh"), &[], true).is_none());
|
||||
|
||||
let inj = setup(Some("nu.exe"), &[], false).expect("nushell setup");
|
||||
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
|
||||
assert_eq!(inj.args[0], "--config");
|
||||
assert!(!inj.replaces_argv);
|
||||
if let Some(d) = inj.dir {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
assert!(setup(Some("nu.exe"), &[], true).is_none());
|
||||
|
||||
assert!(setup(Some("/bin/sh"), &[], false).is_none());
|
||||
}
|
||||
|
||||
@@ -2491,4 +2726,305 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_nushell_writes_config_and_points_at_it() {
|
||||
let inj = setup_nushell().expect("nushell setup should succeed");
|
||||
let dir = inj.dir.clone().expect("nushell needs a throwaway dir");
|
||||
assert_eq!(inj.args[0], "--config");
|
||||
assert_eq!(inj.args.len(), 2);
|
||||
let written = std::fs::read_to_string(&inj.args[1]).expect("config written");
|
||||
assert_eq!(
|
||||
written,
|
||||
nushell_config_script_with(nushell_user_config_path().as_deref())
|
||||
);
|
||||
assert!(inj.env.is_empty());
|
||||
assert!(!inj.replaces_argv);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nushell_integration_emits_every_osc_133_mark_and_cwd() {
|
||||
let s = NUSHELL_INTEGRATION;
|
||||
for mark in ["133;A", "133;B", "133;C", "133;D;"] {
|
||||
assert!(s.contains(mark), "Nushell must emit {mark:?}");
|
||||
}
|
||||
assert!(s.contains("7;file://"));
|
||||
assert!(
|
||||
s.contains(r#"($nu.os-info.name? | default '') == 'windows'"#),
|
||||
"the backslash translation must be gated on Windows — a Unix path \
|
||||
may legally contain a literal backslash"
|
||||
);
|
||||
assert!(
|
||||
s.contains(r#"str replace -a '%' '%25'"#),
|
||||
"must percent-escape"
|
||||
);
|
||||
assert!(s.contains("__tty7_cmd_active"));
|
||||
assert!(s.contains("hide-env __tty7_cmd_active"));
|
||||
assert!(s.contains("prompt_indicator?"));
|
||||
assert!(
|
||||
s.contains(r##"($env.TTY7_SHELL_INTEGRATION? | default "") == """##),
|
||||
"must guard install on the sentinel being empty, like the other shells"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nushell_config_script_substitutes_a_literal_source_or_a_noop() {
|
||||
let with = nushell_config_script_with(Some(Path::new(
|
||||
r"C:\Users\ann\AppData\Roaming\nushell\config.nu",
|
||||
)));
|
||||
assert!(with.contains("source 'C:\\Users\\ann\\AppData\\Roaming\\nushell\\config.nu'"));
|
||||
let source_at = with.find("source ").expect("sources the user config");
|
||||
let hooks_at = with.find("hooks.pre_prompt").expect("appends hooks");
|
||||
assert!(
|
||||
source_at < hooks_at,
|
||||
"the user config must be sourced before the hooks are appended"
|
||||
);
|
||||
|
||||
let without = nushell_config_script_with(None);
|
||||
assert!(
|
||||
!without.contains("source '") && !without.contains("source \""),
|
||||
"no config.nu means nothing to source"
|
||||
);
|
||||
assert!(without.contains("no user config.nu to restore"));
|
||||
assert!(without.contains("hooks.pre_prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nu_string_literal_quotes_paths_for_the_wrapper() {
|
||||
assert_eq!(
|
||||
nu_string_literal(r"C:\Users\ann\nushell\config.nu"),
|
||||
r"'C:\Users\ann\nushell\config.nu'"
|
||||
);
|
||||
assert_eq!(
|
||||
nu_string_literal("/home/ann/.config/nushell/config.nu"),
|
||||
"'/home/ann/.config/nushell/config.nu'"
|
||||
);
|
||||
// An apostrophe (legal on Windows) forces the double-quoted fallback.
|
||||
assert_eq!(
|
||||
nu_string_literal(r"C:\it's\config.nu"),
|
||||
r#""C:\\it's\\config.nu""#
|
||||
);
|
||||
// Single quotes are fully literal in Nushell, so `$` needs no escaping
|
||||
// there; only an apostrophe forces the double-quoted fallback.
|
||||
assert_eq!(
|
||||
nu_string_literal(r"C:\a$b\config.nu"),
|
||||
r"'C:\a$b\config.nu'"
|
||||
);
|
||||
assert_eq!(
|
||||
nu_string_literal(r"C:\it's\$x\config.nu"),
|
||||
r#""C:\\it's\\\$x\\config.nu""#
|
||||
);
|
||||
}
|
||||
|
||||
/// nu-path resolves the config dir from `$XDG_CONFIG_HOME` on every
|
||||
/// platform, but only when it is non-empty *and* absolute. Both halves are
|
||||
/// load-bearing: the original Windows arm ignored the variable outright,
|
||||
/// and the original non-Windows arm accepted a relative value nu rejects.
|
||||
#[test]
|
||||
fn nushell_config_dir_follows_nu_s_own_resolution_rules() {
|
||||
use std::ffi::OsStr;
|
||||
// `/x` is not absolute on Windows (no prefix), so the fixtures have to
|
||||
// be shaped for the host or the rule under test is never exercised.
|
||||
let abs_xdg = if cfg!(windows) { r"C:\xdg" } else { "/xdg" };
|
||||
let platform = || {
|
||||
Some(PathBuf::from(if cfg!(windows) {
|
||||
r"C:\platform"
|
||||
} else {
|
||||
"/platform"
|
||||
}))
|
||||
};
|
||||
let want = |base: &str| Some(PathBuf::from(base).join("nushell"));
|
||||
|
||||
// Unset, empty, or relative: nu ignores it and takes the platform dir.
|
||||
for ignored in [None, Some(OsStr::new("")), Some(OsStr::new("relative/dir"))] {
|
||||
assert_eq!(
|
||||
nushell_config_dir_from(ignored, platform()),
|
||||
want(&platform().unwrap().to_string_lossy()),
|
||||
"XDG_CONFIG_HOME {ignored:?} must not displace the platform dir"
|
||||
);
|
||||
}
|
||||
|
||||
// Non-empty and absolute: it wins, even where there is no platform dir.
|
||||
assert_eq!(
|
||||
nushell_config_dir_from(Some(OsStr::new(abs_xdg)), platform()),
|
||||
want(abs_xdg)
|
||||
);
|
||||
assert_eq!(
|
||||
nushell_config_dir_from(Some(OsStr::new(abs_xdg)), None),
|
||||
want(abs_xdg)
|
||||
);
|
||||
assert_eq!(nushell_config_dir_from(None, None), None);
|
||||
}
|
||||
|
||||
/// The macOS half of the same rule, stated as itself: nu follows the
|
||||
/// platform convention there, which is *not* where tty7 keeps its own
|
||||
/// config (`~/.config/tty7`). Reading `~/.config/nushell` on a Mac finds
|
||||
/// nothing, and the wrapper then replaces a config it thinks is absent.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn nushell_config_dir_is_application_support_on_macos() {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").expect("HOME"));
|
||||
assert_eq!(
|
||||
platform_config_dir(),
|
||||
Some(home.join("Library/Application Support"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Ask the real binary. A hand-copy of nu's resolution rules is only as
|
||||
/// good as the reading behind it — this is the check that fails outright
|
||||
/// if the two ever disagree on this machine.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nushell_config_dir_is_what_nu_itself_reports() {
|
||||
let Some(nu) = test_nushell_path() else {
|
||||
eprintln!("skipping: Nushell not installed");
|
||||
return;
|
||||
};
|
||||
let xdg_absolute = std::env::temp_dir().join("tty7-nu-xdg");
|
||||
let cases: [Option<&std::ffi::OsStr>; 4] = [
|
||||
None,
|
||||
Some(std::ffi::OsStr::new("")),
|
||||
Some(std::ffi::OsStr::new("relative/dir")),
|
||||
Some(xdg_absolute.as_os_str()),
|
||||
];
|
||||
for xdg in cases {
|
||||
let mut cmd = std::process::Command::new(&nu);
|
||||
cmd.args(["-n", "-c", "$nu.default-config-dir"]);
|
||||
match xdg {
|
||||
Some(v) => cmd.env("XDG_CONFIG_HOME", v),
|
||||
None => cmd.env_remove("XDG_CONFIG_HOME"),
|
||||
};
|
||||
let out = cmd.output().expect("run nu");
|
||||
// A relative value makes nu warn before it answers, so take the
|
||||
// last line, not the whole of stdout.
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let theirs = PathBuf::from(stdout.lines().last().unwrap_or_default().trim());
|
||||
let ours = nushell_config_dir_from(xdg, platform_config_dir())
|
||||
.expect("this machine has a config dir");
|
||||
assert_eq!(
|
||||
ours, theirs,
|
||||
"tty7 and nu disagree on the config dir for XDG_CONFIG_HOME={xdg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End to end over a real pty: a user who *has* a config.nu must still
|
||||
/// have it after tty7 points `nu --config` at the wrapper. `--config`
|
||||
/// replaces the user's config rather than adding to it, so a wrapper that
|
||||
/// fails to source it silently strips their prompt, aliases and
|
||||
/// keybindings.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nushell_restores_the_user_config_and_still_reports_the_prompt_cycle() {
|
||||
let Some(nu) = test_nushell_path() else {
|
||||
eprintln!("skipping: Nushell not installed");
|
||||
return;
|
||||
};
|
||||
let nu = nu.to_string_lossy().into_owned();
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let user_config = home.path().join("config.nu");
|
||||
// Shaped like a real config.nu: a wholesale `$env.config = {...}` (the
|
||||
// conventional ending, and the one that would wipe hooks added before
|
||||
// it), plus a marker only the user's own file could have set.
|
||||
std::fs::write(
|
||||
&user_config,
|
||||
"$env.config = { show_banner: false }\n$env.TTY7_USER_CONFIG_LOADED = \"yes\"\n",
|
||||
)
|
||||
.expect("write user config");
|
||||
|
||||
let mut injection = setup_nushell_with(Some(&user_config)).expect("nushell integration");
|
||||
// What `setup` does at every spawn boundary, and what this test
|
||||
// bypasses by reaching for `setup_nushell_with` directly. Without it
|
||||
// the wrapper is a no-op whenever the test itself runs inside an
|
||||
// integrated pane — the guard sees the inherited `1` and skips the
|
||||
// whole block, and the marks in the transcript are then Nushell's own.
|
||||
injection
|
||||
.env
|
||||
.insert("TTY7_SHELL_INTEGRATION".to_string(), String::new());
|
||||
let text = prompt_cycle_over_pty(
|
||||
&nu,
|
||||
&injection,
|
||||
// One line: Nushell's line editor holds the tty in raw mode and
|
||||
// drops what arrives while a command is running, so a second `\r`
|
||||
// never reaches it and its prompt cycle never happens. `1 / 0` is
|
||||
// the failure — a bare `false` in Nushell is a value.
|
||||
b"print $\"probe=($env.TTY7_USER_CONFIG_LOADED? | default 'MISSING')\"; 1 / 0\r",
|
||||
Some(home.path()),
|
||||
);
|
||||
|
||||
assert!(
|
||||
text.contains("probe=yes"),
|
||||
"the user's own config.nu must survive --config; got:\n{text}"
|
||||
);
|
||||
// Assert on tty7's *own* marks, not on the substring `133;A`: stock
|
||||
// Nushell emits its own OSC 133 cycle alongside these, so a bare
|
||||
// substring passes even when the wrapper never ran. tty7's hooks
|
||||
// terminate with BEL and Nushell's with ST, which tells them apart.
|
||||
for mark in ["133;A\u{7}", "133;C\u{7}", FAILED_COMMAND_MARK] {
|
||||
assert!(
|
||||
text.contains(mark),
|
||||
"Nushell must still report {mark:?} after sourcing the user config; got:\n{text}"
|
||||
);
|
||||
}
|
||||
// Same reasoning for the cwd: the hook falls back to `localhost` for
|
||||
// the host, while Nushell's native OSC 7 names the real one.
|
||||
assert!(
|
||||
text.contains("7;file://localhost/"),
|
||||
"tty7's own OSC 7 must be among the reports; got:\n{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
last_osc7(&text),
|
||||
home.path().canonicalize().expect("canonical tempdir"),
|
||||
"the pane's reported cwd must be where nu actually is"
|
||||
);
|
||||
}
|
||||
|
||||
/// `shells::nushell_path` is Windows-only (nu has no fixed install path
|
||||
/// there); on unix the binary is whatever `nu` resolves to on PATH.
|
||||
#[cfg(unix)]
|
||||
fn test_nushell_path() -> Option<PathBuf> {
|
||||
std::env::split_paths(&std::env::var_os("PATH")?)
|
||||
.map(|dir| dir.join("nu"))
|
||||
.find(|p| p.is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn nushell_reports_the_full_prompt_cycle_over_a_real_pty() {
|
||||
let Some(nu) = crate::core::shells::nushell_path() else {
|
||||
eprintln!("skipping: Nushell not installed");
|
||||
return;
|
||||
};
|
||||
let nu = nu.to_string_lossy().into_owned();
|
||||
let injection = setup(Some(&nu), &[], false).expect("nushell integration");
|
||||
// One line: Nushell's line editor holds the tty in raw mode and can
|
||||
// drop what arrives while a command is running, so a second `\r` may
|
||||
// never reach it. `cd` first, then a command that fails — `1 / 0`
|
||||
// errors with exit status 1 (a literal `false` is just a value).
|
||||
let text = prompt_cycle_over_pty(
|
||||
&nu,
|
||||
&injection,
|
||||
b"cd C:/Windows; 1 / 0\r",
|
||||
Some(Path::new("C:/")),
|
||||
);
|
||||
|
||||
for mark in ["133;A", "133;B", "133;C", FAILED_COMMAND_MARK] {
|
||||
assert!(
|
||||
text.contains(mark),
|
||||
"Nushell must report {mark:?}; got:\n{text}"
|
||||
);
|
||||
}
|
||||
let cwd = reported_cwd(&text);
|
||||
assert!(
|
||||
cwd.exists(),
|
||||
"Nushell reported a cwd the Windows side cannot resolve: {cwd:?} \
|
||||
— a drive-relative path, so the URI leading-slash translation regressed"
|
||||
);
|
||||
assert_eq!(
|
||||
last_osc7(&text),
|
||||
PathBuf::from("C:/Windows"),
|
||||
"`cd` must move the pane's reported cwd; got:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user