diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d196ee..a6f4997b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `tty7 wait` on it only ever times out, so the check belongs in the verb people run when something is not working. +- **SSH probes the `~/.ssh` default identity keys** — a connection with no + identity file of its own used to offer the server nothing unless an agent + was running, which on Windows is the common case (the OpenSSH + Authentication Agent service is off by default), and then reported "no + public key was accepted" when no key had ever been sent. `id_ed25519`, + `id_ecdsa` and `id_rsa` are now offered after the connection's own files + and before the agent, OpenSSH-style, deduplicated against the explicit list + by canonical path so one key spelled two ways is offered once — every offer + spends one of the server's `MaxAuthTries`. A discovered key that is + encrypted is used only when its passphrase is already in the OS keychain: + russh has no offer-without-signing probe, so asking would spend a prompt on + a key the server may not even want. A key named in the profile still asks, + as before. The failure text now separates the two situations the old line + papered over — keys the server rejected are named, and a round that offered + nothing says where it looked. (#484) + ### Changed - **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 8f5db096..8fcbfdad 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -288,27 +288,64 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin expand_tilde(&out) } -pub fn expand_tilde(path: &str) -> String { - let home = || { - #[cfg(windows)] - let var = "USERPROFILE"; - #[cfg(not(windows))] - let var = "HOME"; - std::env::var(var).ok().filter(|h| !h.is_empty()) - }; +/// The platform home directory: `%USERPROFILE%` on Windows, `$HOME` elsewhere. +fn home_dir() -> Option { + #[cfg(windows)] + let var = "USERPROFILE"; + #[cfg(not(windows))] + let var = "HOME"; + std::env::var(var).ok().filter(|h| !h.is_empty()) +} + +fn expand_tilde_with(path: &str, home: Option<&str>) -> String { if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = home() { + if let Some(home) = home { let sep = if home.ends_with('/') { "" } else { "/" }; return format!("{home}{sep}{rest}"); } } else if path == "~" { - if let Some(home) = home() { - return home; + if let Some(home) = home { + return home.to_string(); } } path.to_string() } +pub fn expand_tilde(path: &str) -> String { + expand_tilde_with(path, home_dir().as_deref()) +} + +/// The private keys publickey auth probes when a connection carries no usable +/// `IdentityFile` of its own — OpenSSH's default-identity behaviour (issue +/// #484). Without it, "no profile key + no agent" offers the server zero keys, +/// which on Windows is the common case (the OpenSSH Authentication Agent +/// service is disabled by default there). +/// +/// Both the GUI (`ui::ssh_connect`, preloading cached passphrases) and the +/// daemon (`daemon::ssh::auth`, offering the keys) must see the *same* list: +/// `NativeSshSpec::key_passphrases` is keyed on these exact strings, so the +/// two sides share this one definition rather than formatting their own. +/// +/// The list stays short on purpose: every offered key spends one of the +/// server's `MaxAuthTries` (default 6), shared with explicit keys and agent +/// identities. `id_dsa` is long deprecated, `id_xmss`/`id_*_sk` are beyond +/// what russh can sign with, so the three software keys cover what exists in +/// practice — ed25519 first as the modern default. +pub fn default_identity_candidates() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + default_identity_candidates_in(&home) +} + +/// The pure core, home injected so tests never touch the environment. +fn default_identity_candidates_in(home: &str) -> Vec { + ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|name| expand_tilde_with(&format!("~/.ssh/{name}"), Some(home))) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -459,6 +496,25 @@ mod tests { assert_eq!(expand_tilde("/abs/path"), "/abs/path"); } + #[test] + fn default_identity_candidates_are_ordered_and_home_relative() { + assert_eq!( + default_identity_candidates_in("/home/me"), + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string(), + "/home/me/.ssh/id_rsa".to_string() + ] + ); + // A trailing separator must not double up, and the strings must be + // exactly what an explicit `~/.ssh/...` entry expands to, because + // `key_passphrases` is keyed on them. + assert_eq!( + default_identity_candidates_in("/home/me/"), + default_identity_candidates_in("/home/me") + ); + } + #[test] fn profile_expanded_identity_files_uses_own_host_user() { let mut p = SshProfile::new("x"); diff --git a/crates/tty7-core/src/daemon/ssh/auth.rs b/crates/tty7-core/src/daemon/ssh/auth.rs index f8661e96..1e8412c4 100644 --- a/crates/tty7-core/src/daemon/ssh/auth.rs +++ b/crates/tty7-core/src/daemon/ssh/auth.rs @@ -439,10 +439,36 @@ async fn try_publickeys( broker: &Arc, ) -> Outcome { let mut last: Option = None; + let mut round = KeyRound::default(); if spec.auth_mode != SshAuthMode::Agent { - for path in &spec.identity_files { - match try_identity_file(handle, spec, broker, path).await { + // OpenSSH parity (#484): the `~/.ssh` default identities are appended + // after the explicit ones (there is no `IdentitiesOnly` yet), and + // deduped against them by canonical path — the explicit list may spell + // the same key with different separators or casing, and every offer + // spends one of the server's MaxAuthTries. Dedup compares the *expanded* + // explicit paths, the same ones `try_identity_file` opens: a spec entry + // still carrying `~` or `%h` names a real file, and comparing it raw + // would fail to canonicalize and offer that key a second time. + let explicit: Vec = spec + .identity_files + .iter() + .map(|p| { + crate::core::ssh_profile::expand_identity_placeholders(p, &spec.host, &spec.user) + }) + .collect(); + let discovered = dedup_candidates( + crate::core::ssh_profile::default_identity_candidates(), + &explicit, + canonical_key, + ); + let files = spec + .identity_files + .iter() + .map(|p| (p.clone(), KeySource::Explicit)) + .chain(discovered.into_iter().map(|p| (p, KeySource::Discovered))); + for (path, source) in files { + match try_identity_file(handle, spec, broker, &path, source, &mut round).await { Outcome::Authenticated => return Outcome::Authenticated, Outcome::Failed { remaining_methods, .. @@ -457,7 +483,7 @@ async fn try_publickeys( } if spec.auth_mode != SshAuthMode::PublicKey { - match try_agent(handle, spec).await { + match try_agent(handle, spec, &mut round).await { Outcome::Authenticated => return Outcome::Authenticated, Outcome::Failed { remaining_methods, .. @@ -472,7 +498,190 @@ async fn try_publickeys( Outcome::Failed { remaining_methods: last, - reason: Some("no public key was accepted".to_string()), + reason: Some(round.reason(spec.auth_mode)), + } +} + +/// Where an identity file came from. Provenance decides failure behaviour: +/// an explicit key is the user's own choice, so its failures are said aloud +/// and its encrypted form may ask for a passphrase; a discovered `~/.ssh` +/// default is none of the user's doing, so every failure of one is silent +/// (#484). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeySource { + Explicit, + Discovered, +} + +/// Canonical path for dedup: the same key reached via `~`, an absolute path, +/// or different separator/casing spellings must be offered once, not twice — +/// each offer spends one of the server's MaxAuthTries. Files that cannot be +/// canonicalized (missing) never enter the set; the read step skips them. +fn canonical_key(path: &str) -> Option { + std::fs::canonicalize(path) + .ok() + .map(|p| p.to_string_lossy().into_owned()) +} + +/// Drop default candidates an explicit entry already names, comparing by +/// canonical path. Pure apart from the injected canonicalizer, so tests never +/// touch the filesystem. +fn dedup_candidates( + candidates: Vec, + explicit: &[String], + canon: impl Fn(&str) -> Option, +) -> Vec { + let mut seen: std::collections::HashSet = + explicit.iter().filter_map(|p| canon(p)).collect(); + let mut out = Vec::new(); + for candidate in candidates { + match canon(&candidate) { + Some(key) if seen.contains(&key) => {} + Some(key) => { + seen.insert(key); + out.push(candidate); + } + // Not canonicalizable means not readable; the read step skips it. + None => out.push(candidate), + } + } + out +} + +/// What one publickey round learned, kept so the final error can distinguish +/// the two situations "no public key was accepted" used to paper over +/// (#484): nothing local could be offered at all, or keys went to the server +/// and it refused every one. +#[derive(Default)] +struct KeyRound { + /// File keys actually sent to the server, by their configured path. + offered_files: Vec, + /// File keys the server rejected, same spelling. + rejected_files: Vec, + /// Whether an agent answered, and how many of its identities were + /// sent / rejected. + agent_available: bool, + agent_offered: usize, + agent_rejected: usize, + /// Explicit files that could not be read or decoded, with the reason. + /// (Discovered candidates fail silently, so they never land here.) + unusable: Vec, + /// Transport-level errors after a key was decoded. + errors: Vec, +} + +impl KeyRound { + fn reason(&self, mode: SshAuthMode) -> String { + if !self.rejected_files.is_empty() || self.agent_rejected > 0 { + let mut what = self.rejected_files.clone(); + if self.agent_rejected > 0 { + what.push(format!( + "{} agent {}", + self.agent_rejected, + if self.agent_rejected == 1 { + "identity" + } else { + "identities" + } + )); + } + return format!("server rejected public key(s): {}", what.join(", ")); + } + if self.offered_files.is_empty() && self.agent_offered == 0 { + let mut looked: Vec = Vec::new(); + if mode != SshAuthMode::Agent { + looked.push("identity files".to_string()); + looked.push("~/.ssh default keys".to_string()); + } + if mode != SshAuthMode::PublicKey { + looked.push(if self.agent_available { + "the SSH agent".to_string() + } else { + "the SSH agent (unavailable)".to_string() + }); + } + let mut msg = format!( + "no usable private key was found (checked: {})", + looked.join(", ") + ); + if !self.unusable.is_empty() { + msg.push_str(&format!("; {}", self.unusable.join("; "))); + } + return msg; + } + // Keys were offered and none was rejected or accepted: the transport + // broke, and the last error says where. + if let Some(e) = self.errors.last() { + return e.clone(); + } + "no public key was accepted".to_string() + } +} + +/// Decode-time policy for one identity file, split from the network so the +/// source × encryption matrix stays unit-testable. The asymmetry is the +/// point (#484 review): russh has no offer-without-signature probe, so +/// trying an encrypted key means signing — i.e. prompting *before* the server +/// has shown any interest in that key. An explicit key earns that prompt; a +/// discovered default with no cached passphrase does not. +enum IdentityLoad { + Ready(russh::keys::PrivateKey), + /// Not worth an offer: a `.pub`, an undecodable file, or a discovered + /// candidate that is encrypted with no cached passphrase. + Skip, + /// An explicit key the user should hear about. + Unusable(String), + /// Explicit, encrypted, no cached passphrase — ask the user. + NeedsPassphrase, +} + +fn load_identity( + contents: &str, + raw_path: &str, + source: KeySource, + cached: Option<&str>, +) -> IdentityLoad { + if PublicKey::from_openssh(contents.trim()).is_ok() { + // A `.pub` handed in as the identity file is never an offer. Worth a + // line in the log when the user named it themselves — pointing + // IdentityFile at the public half is a common slip, and the round is + // otherwise silent about it. + if source == KeySource::Explicit { + log::warn!("identity file {raw_path} is a public key; skipping"); + } + return IdentityLoad::Skip; + } + match russh::keys::decode_secret_key(contents, None) { + Ok(key) => IdentityLoad::Ready(key), + Err(russh::keys::Error::KeyIsEncrypted) => match cached { + Some(passphrase) => match russh::keys::decode_secret_key(contents, Some(passphrase)) { + Ok(key) => IdentityLoad::Ready(key), + Err(e) => { + log::warn!("could not decrypt identity file {raw_path}: {e}"); + match source { + KeySource::Explicit => IdentityLoad::Unusable(format!( + "could not decrypt identity file {raw_path}" + )), + // A stale cached passphrase for a key the user never + // configured: skip, don't shout. + KeySource::Discovered => IdentityLoad::Skip, + } + } + }, + None => match source { + KeySource::Explicit => IdentityLoad::NeedsPassphrase, + KeySource::Discovered => IdentityLoad::Skip, + }, + }, + Err(e) => { + log::warn!("could not read identity file {raw_path}: {e}"); + match source { + KeySource::Explicit => { + IdentityLoad::Unusable(format!("could not read identity file {raw_path}: {e}")) + } + KeySource::Discovered => IdentityLoad::Skip, + } + } } } @@ -481,77 +690,110 @@ async fn try_identity_file( spec: &NativeSshSpec, broker: &Arc, raw_path: &str, + source: KeySource, + round: &mut KeyRound, ) -> Outcome { - let path = expand_identity_path(raw_path, &spec.host, &spec.user); + let path = + crate::core::ssh_profile::expand_identity_placeholders(raw_path, &spec.host, &spec.user); let contents = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(e) => return failed(format!("cannot read identity file {path}: {e}")), - }; - - if PublicKey::from_openssh(contents.trim()).is_ok() { - log::warn!("identity file {path} is a public key; skipping"); - return Outcome::Skipped; - } - - let key = match russh::keys::decode_secret_key(&contents, None) { - Ok(k) => k, - Err(russh::keys::Error::KeyIsEncrypted) => { - let provided = spec - .key_passphrases - .as_ref() - .and_then(|m| m.get(raw_path)) - .cloned(); - let passphrase = match provided { - Some(p) => p, - None => { - let resp = broker - .prompt(AuthPromptKind::KeyPassphrase { - key_path: raw_path.to_string(), - comment: String::new(), - }) - .await; - match resp { - AuthResponse::Secret(p) => p, - _ => return Outcome::Skipped, + Err(e) => { + return match source { + KeySource::Explicit => { + round + .unusable + .push(format!("cannot read identity file {raw_path}: {e}")); + Outcome::Failed { + remaining_methods: None, + reason: None, } } + // A default candidate that is not there is the normal case, + // not a failure. + KeySource::Discovered => Outcome::Skipped, + }; + } + }; + + let cached = spec + .key_passphrases + .as_ref() + .and_then(|m| m.get(raw_path)) + .map(String::as_str); + let key = match load_identity(&contents, raw_path, source, cached) { + IdentityLoad::Ready(k) => k, + IdentityLoad::Skip => return Outcome::Skipped, + IdentityLoad::Unusable(reason) => { + round.unusable.push(reason); + return Outcome::Failed { + remaining_methods: None, + reason: None, + }; + } + IdentityLoad::NeedsPassphrase => { + let resp = broker + .prompt(AuthPromptKind::KeyPassphrase { + key_path: raw_path.to_string(), + comment: String::new(), + }) + .await; + let AuthResponse::Secret(passphrase) = resp else { + return Outcome::Skipped; }; match russh::keys::decode_secret_key(&contents, Some(&passphrase)) { Ok(k) => k, Err(e) => { log::warn!("could not decrypt identity file {path}: {e}"); - return failed(format!("could not decrypt identity file {path}")); + round + .unusable + .push(format!("could not decrypt identity file {raw_path}")); + return Outcome::Failed { + remaining_methods: None, + reason: None, + }; } } } - Err(e) => { - log::warn!("could not read identity file {path}: {e}"); - return failed(format!("could not read identity file {path}")); - } }; + round.offered_files.push(raw_path.to_string()); let hash_alg = rsa_hash_alg(&key.algorithm()); let pk = PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg); match handle.authenticate_publickey(&spec.user, pk).await { Ok(AuthResult::Success) => Outcome::Authenticated, Ok(AuthResult::Failure { remaining_methods, .. - }) => Outcome::Failed { - remaining_methods: Some(remaining_methods), - reason: Some(format!("server rejected key {raw_path}")), - }, - Err(e) => failed(format!("public-key auth error: {e}")), + }) => { + round.rejected_files.push(raw_path.to_string()); + Outcome::Failed { + remaining_methods: Some(remaining_methods), + reason: None, + } + } + Err(e) => { + round + .errors + .push(format!("public-key auth error with {raw_path}: {e}")); + Outcome::Failed { + remaining_methods: None, + reason: None, + } + } } } -async fn try_agent(handle: &mut Handle, spec: &NativeSshSpec) -> Outcome { +async fn try_agent( + handle: &mut Handle, + spec: &NativeSshSpec, + round: &mut KeyRound, +) -> Outcome { #[cfg(unix)] { let agent = match AgentClient::connect_env().await { Ok(a) => a, Err(_) => return Outcome::Skipped, }; - try_agent_identities(handle, spec, agent).await + try_agent_identities(handle, spec, agent, round).await } #[cfg(windows)] { @@ -561,7 +803,7 @@ async fn try_agent(handle: &mut Handle, spec: &NativeSshSpec) -> Ok(a) => a, Err(_) => return Outcome::Skipped, }; - try_agent_identities(handle, spec, agent).await + try_agent_identities(handle, spec, agent, round).await } } @@ -569,6 +811,7 @@ async fn try_agent_identities( handle: &mut Handle, spec: &NativeSshSpec, mut agent: AgentClient, + round: &mut KeyRound, ) -> Outcome where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, @@ -577,12 +820,14 @@ where Ok(ids) => ids, Err(_) => return Outcome::Skipped, }; + round.agent_available = true; let mut last: Option = None; for identity in identities { let pubkey: PublicKey = match &identity { AgentIdentity::PublicKey { key, .. } => key.clone(), AgentIdentity::Certificate { .. } => continue, }; + round.agent_offered += 1; let hash_alg = rsa_hash_alg(&pubkey.algorithm()); match handle .authenticate_publickey_with(&spec.user, pubkey, hash_alg, &mut agent) @@ -591,13 +836,16 @@ where Ok(AuthResult::Success) => return Outcome::Authenticated, Ok(AuthResult::Failure { remaining_methods, .. - }) => last = Some(remaining_methods), + }) => { + round.agent_rejected += 1; + last = Some(remaining_methods); + } Err(_) => continue, } } Outcome::Failed { remaining_methods: last, - reason: Some("no agent key was accepted".to_string()), + reason: None, } } @@ -756,26 +1004,6 @@ fn rsa_hash_alg(algorithm: &Algorithm) -> Option { } } -fn expand_identity_path(path: &str, host: &str, user: &str) -> String { - let substituted = path.replace("%h", host).replace("%r", user); - if let Some(rest) = substituted.strip_prefix("~/") { - if let Some(home) = home_dir() { - return format!("{home}/{rest}"); - } - } - substituted -} - -#[cfg(unix)] -fn home_dir() -> Option { - std::env::var("HOME").ok().filter(|h| !h.is_empty()) -} - -#[cfg(not(unix))] -fn home_dir() -> Option { - std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()) -} - #[cfg(test)] mod tests { use super::*; @@ -805,12 +1033,6 @@ mod tests { assert!(!msg.ends_with(' '), "{msg}"); } - #[test] - fn identity_path_expands_tokens_and_tilde() { - let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy"); - assert_eq!(p, "/keys/deploy@example.com/id"); - } - #[test] fn method_order_restricts_by_mode() { assert_eq!( @@ -871,4 +1093,229 @@ mod tests { ); assert_eq!(rsa_hash_alg(&Algorithm::Ed25519), None); } + + #[test] + fn default_candidates_dedup_against_explicit_by_canonical_path() { + // The fake canonicalizer collapses spelling differences; two strings + // with the same canonical form are one file, and the explicit entry + // wins the offer slot. + let canon = |p: &str| Some(p.replace("//", "/")); + let out = dedup_candidates( + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string(), + "/home/me/.ssh/id_rsa".to_string(), + ], + &["/home/me//.ssh/id_rsa".to_string()], + canon, + ); + assert_eq!( + out, + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string() + ] + ); + } + + #[test] + fn candidates_that_do_not_canonicalize_pass_through() { + // A missing default is the normal case; the read step skips it, so + // dedup must not drop it here either. + let out = dedup_candidates(vec!["/missing/id_ed25519".to_string()], &[], |_| None); + assert_eq!(out, vec!["/missing/id_ed25519".to_string()]); + } + + const PASSPHRASE: &str = "correct horse battery staple"; + + /// The throwaway ed25519 key these tests offer, built here rather than + /// pasted in as a PEM blob: a private key sitting in the tree is a + /// secret-scanner hit whatever its provenance, and a scanner that has to + /// be overridden to stay green is one nobody reads. The seed is fixed, so + /// the bytes are the same on every run, and this key exists nowhere but + /// these assertions. + fn fixture_key() -> russh::keys::PrivateKey { + russh::keys::PrivateKey::from(russh::keys::ssh_key::private::Ed25519Keypair::from_seed( + &[7u8; 32], + )) + } + + fn plain_key() -> String { + fixture_key() + .to_openssh(russh::keys::ssh_key::LineEnding::LF) + .expect("encode the fixture key") + .to_string() + } + + /// The same key under `PASSPHRASE`. `encrypt_with` takes the KDF and + /// checkint rather than an RNG, which is what keeps this crate free of a + /// rand dependency it otherwise has no use for; the low bcrypt round count + /// is a test's, not a real key's. + fn encrypted_key() -> String { + fixture_key() + .encrypt_with( + russh::keys::ssh_key::Cipher::Aes256Ctr, + russh::keys::ssh_key::Kdf::Bcrypt { + salt: vec![9u8; 16], + rounds: 4, + }, + 0, + PASSPHRASE, + ) + .expect("encrypt the fixture key") + .to_openssh(russh::keys::ssh_key::LineEnding::LF) + .expect("encode the encrypted fixture key") + .to_string() + } + + #[test] + fn load_identity_ready_for_plain_key_either_source() { + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&plain_key(), "k", source, None), + IdentityLoad::Ready(_) + ), + "plain key must load for {source:?}" + ); + } + } + + #[test] + fn load_identity_skips_public_key_content() { + let public = fixture_key() + .public_key() + .to_openssh() + .expect("encode the fixture public key"); + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&public, "k", source, None), + IdentityLoad::Skip + ), + "a .pub is never an offer" + ); + } + } + + #[test] + fn load_identity_garbage_is_loud_for_explicit_quiet_for_discovered() { + assert!(matches!( + load_identity("not a key", "k", KeySource::Explicit, None), + IdentityLoad::Unusable(_) + )); + assert!(matches!( + load_identity("not a key", "k", KeySource::Discovered, None), + IdentityLoad::Skip + )); + } + + #[test] + fn load_identity_encrypted_prompts_only_for_explicit() { + // The whole policy (#484): russh can only try an encrypted key by + // signing, so a discovered one with no cached passphrase is skipped + // rather than spending a prompt on a key the server may not want. + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Explicit, None), + IdentityLoad::NeedsPassphrase + )); + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Discovered, None), + IdentityLoad::Skip + )); + } + + #[test] + fn load_identity_encrypted_uses_a_cached_passphrase_for_either_source() { + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&encrypted_key(), "k", source, Some(PASSPHRASE)), + IdentityLoad::Ready(_) + ), + "cached passphrase must unlock for {source:?}" + ); + } + } + + #[test] + fn load_identity_wrong_cached_passphrase_is_loud_only_for_explicit() { + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Explicit, Some("wrong")), + IdentityLoad::Unusable(_) + )); + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Discovered, Some("wrong")), + IdentityLoad::Skip + )); + } + + #[test] + fn reason_names_the_keys_the_server_rejected() { + let mut round = KeyRound::default(); + round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()]; + round.rejected_files = round.offered_files.clone(); + let msg = round.reason(SshAuthMode::Auto); + assert_eq!( + msg, + "server rejected public key(s): /home/me/.ssh/id_ed25519" + ); + + round.agent_offered = 2; + round.agent_rejected = 2; + let msg = round.reason(SshAuthMode::Auto); + assert_eq!( + msg, + "server rejected public key(s): /home/me/.ssh/id_ed25519, 2 agent identities" + ); + } + + #[test] + fn reason_for_nothing_offered_says_where_it_looked() { + let round = KeyRound::default(); + let msg = round.reason(SshAuthMode::Auto); + assert!(msg.contains("no usable private key was found"), "{msg}"); + assert!(msg.contains("~/.ssh default keys"), "{msg}"); + assert!(msg.contains("agent (unavailable)"), "{msg}"); + + // An agent that answered but held nothing is "checked", not + // "unavailable". + let mut round = KeyRound::default(); + round.agent_available = true; + let msg = round.reason(SshAuthMode::Auto); + assert!(msg.contains("the SSH agent"), "{msg}"); + assert!(!msg.contains("unavailable"), "{msg}"); + + // Pinned modes name only what they would have used. + let msg = KeyRound::default().reason(SshAuthMode::Agent); + assert!(!msg.contains("default keys"), "{msg}"); + let msg = KeyRound::default().reason(SshAuthMode::PublicKey); + assert!(!msg.contains("agent"), "{msg}"); + } + + #[test] + fn reason_appends_unusable_explicit_files() { + let mut round = KeyRound::default(); + round + .unusable + .push("cannot read identity file /bad/key: denied".to_string()); + let msg = round.reason(SshAuthMode::PublicKey); + assert!( + msg.contains("cannot read identity file /bad/key: denied"), + "{msg}" + ); + } + + #[test] + fn reason_falls_back_to_the_transport_error_after_an_offer() { + let mut round = KeyRound::default(); + round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()]; + round.errors.push( + "public-key auth error with /home/me/.ssh/id_ed25519: connection lost".to_string(), + ); + assert_eq!( + round.reason(SshAuthMode::Auto), + "public-key auth error with /home/me/.ssh/id_ed25519: connection lost" + ); + } } diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index 7f70818f..bc8f685b 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -55,6 +55,6 @@ tty7 itself. ## Copy the session id -**Copy Session ID** — in the pane's right-click menu, beside *Copy Working +**Copy Session ID** — in the tab's right-click menu, beside *Copy Working Directory*, and in the command palette — puts the agent's native id on the clipboard. Paste it into `codex resume`, a bug report, or another tool. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index c141ecab..70eab33c 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,20 +14,24 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · Copilot · OpenCode · Pi · Grok · Oh My Pi | Hooks available | +| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available | | Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet | -Installing writes into that agent's own configuration directory and can be -undone from the same row — the button becomes **Uninstall**, and reads -**Outdated** with an **Update** when tty7 ships a newer hook. +Installing writes into that agent's own configuration directory. Once installed +the row grows a second **Uninstall** button beside the first, which itself +becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7 +ships a newer hook. The hooks only do anything inside tty7. Running the same agent in another terminal is unaffected. -Connected [remote machines](/remote/workspaces) get their own row, so an agent -running on a dev box reports status to the window you are watching it from. +Hooks are installed per machine. Once a second +[machine](/remote/workspaces) is linked, a row of chips appears above the table +to pick which one you are looking at, and the table then shows that machine's +agents — so a dev box gets its hooks installed the same way, from the same +screen. ## The status dot diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index ac1c7336..0754bc8b 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -22,9 +22,9 @@ server is reachable, whether its wire dialect matches this binary, and whether running *inside* a tty7 pane. Being inside a pane matters because the address-taking verbs (`split`, `send`, -`capture`, `procs`, `pane close`) default to `$TTY7_PANE`, and `run --keep` -files its pane into `$TTY7_WS`. Outside one you must name a target, and the -error says so rather than guessing. +`capture`, `procs`, `wait`, `pane close`) default to `$TTY7_PANE`, and +`run --keep` files its pane into `$TTY7_WS`. Outside one you must name a target, +and the error says so rather than guessing. ## Addresses diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 0c0d87fd..58dfcf5f 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -19,7 +19,7 @@ Set inside every tty7 pane, inherited by anything launched from one. | Variable | Meaning | |---|---| -| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `pane close`. | +| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. | | `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `ws tree`. | | `TTY7_CONFIG_DIR` | The server's config dir — how the CLI finds the right server. You never pass a socket path. | @@ -43,6 +43,16 @@ a stand-in. ## Top-level verbs +### `tty7 [PATH]` + +No subcommand means the GUI. A running window is asked to come forward and open +a tab at `PATH`; if none is registered, the app is launched instead. +JSON: `{"path","delivered","launched"}` — `delivered` says an existing window +took it, `launched` that a new process was started. + +Without `PATH` it just activates the app. `-m` is refused: this verb drives the +GUI on *this* machine. + ### `tty7 ls` Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`. @@ -208,7 +218,7 @@ candidates. | Command | Effect | JSON | |---|---|---| | `ws ls` | Every workspace | `{"workspaces":[...]}` | -| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","tabs":[{"id","name","sidebar_group","root",…}]}` | +| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` | | `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` | | `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` | | `ws rm WORKSPACE` | Delete the workspace | `{"removed"}` | @@ -262,9 +272,9 @@ a stand-in. | `pane close --orphans` | Close every pane no workspace holds | `{"closed":[…]}` | `--all` is the one that shows leaks. Each entry is -`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is -`tty7-cli` for panes this CLI spawned, and `orphan: true` means no workspace -holds it. An interrupted `run` and a removed workspace both leave orphans here. +`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id +of the workspace that owns the pane, and `orphan: true` means no workspace holds +it. An interrupted `run` and a removed workspace both leave orphans here. `--orphans` is the reaper for exactly those. It closes what `pane ls --all` lists as orphaned and nothing else — panes a workspace holds are untouched — @@ -309,4 +319,3 @@ These parse and then exit 1 with an explanation: - `ws stop` — the control dialect has no workspace-stop request yet - `machine connect` / `machine disconnect` — use the GUI's connection manager -- bare `tty7 ` (launch or focus the GUI) diff --git a/docs/customization/fonts.mdx b/docs/customization/fonts.mdx index 544fc5ba..29777c6d 100644 --- a/docs/customization/fonts.mdx +++ b/docs/customization/fonts.mdx @@ -33,10 +33,17 @@ turn for anything the primary lacks. } ``` -The defaults name faces the host OS actually ships — PingFang SC and Apple Color -Emoji on macOS, Microsoft YaHei and Segoe UI Emoji on Windows, Noto on Linux. -Those stock names are appended to whatever list you write, too, so a -`config.json` copied from another platform still resolves. +Leave `font_fallbacks` out and you get the platform's default chain: + +| | Default fallbacks, in order | +|---|---| +| **macOS** | Menlo · Hasklug Nerd Font Mono · Maple Mono NF CN · PingFang SC · Apple Color Emoji | +| **Windows** | Maple Mono NF CN · Cascadia Mono · Microsoft YaHei · Segoe UI Emoji | +| **Linux** | Maple Mono NF CN · DejaVu Sans Mono · Noto Sans CJK SC · Noto Color Emoji | + +Each ends in faces the host OS actually ships, and those stock names are +appended to whatever list you write yourself — so a `config.json` copied from +another platform still resolves. ## OpenType features @@ -65,10 +72,12 @@ stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. Those glyphs get left-aligned in the slot, leaving a ~0.2em gap on the right of every character. -[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is tried first on -every platform for exactly this reason: 0.6em Latin, 1.2em CJK, an exact -two-cell fit against Hack. It is referenced by name only, never bundled (~20 MB -per weight) — install it and tty7 picks it up with no config change. +[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is in every +platform's default chain for exactly this reason: 0.6em Latin, 1.2em CJK, an +exact two-cell fit against Hack. It leads the chain on Windows and Linux, and on +macOS sits behind Menlo and Hasklug, which cover Latin and Nerd Font glyphs +first. It is referenced by name only, never bundled (~20 MB per weight) — +install it and tty7 picks it up with no config change. If you want CJK set *tight* rather than merely even, change the **primary** face instead. One that advances 0.5em — Sarasa Mono SC, say — makes two columns diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx index 5c2441f3..836d825a 100644 --- a/docs/git/diffs.mdx +++ b/docs/git/diffs.mdx @@ -35,7 +35,7 @@ Two limits keep a huge diff from becoming a huge wait: | Limit | Value | What happens | |---|---|---| -| Files rendered | 300 | *"Showing the first 300 of N changes."* | +| Files rendered | 300 | *"… and N more changed files — run git diff in the terminal to see them."* | | Lines before auto-collapse | 400 per file | Big files start collapsed; expand the ones you care about | Both are stated in the overlay when they apply — nothing is dropped silently. diff --git a/docs/git/source-control.mdx b/docs/git/source-control.mdx index da3f23f2..b0c0b4ef 100644 --- a/docs/git/source-control.mdx +++ b/docs/git/source-control.mdx @@ -44,16 +44,28 @@ there too. ## Branches and remotes +The branch name at the top of the panel is a dropdown. It holds: + | | | |---|---| -| **Checkout to…** | Switch branches, with a search box; offers **Stash & Switch** when the tree is dirty | +| **The branch list** | Click one to check it out. Past a dozen branches the list scrolls instead of growing past the window | | **Create Branch…** | From here, or from any commit in the history | -| **Publish Branch** | For a branch with no upstream yet | -| **Sync Changes** | Pull, then push | -| **Push** · **Pull** · **Fetch** | Individually | +| **Fetch** · **Pull** · **Push** | Individually | +| **Switch Repository** | Only when the window has panes in more than one repo | -All of these are also in the command palette under **Git**, so they are -bindable. +Beside it, the sync button pulls then pushes — and relabels itself **Publish +Branch** when the branch has no upstream yet. + +The command palette carries the verbs under **Git** — *Git: Commit*, *Stage +All*, *Unstage All*, *Discard All*, *Create Branch*, *Sync*, *Push*, *Pull*, +*Fetch* — so those are bindable. Checking out is a pick rather than a verb, so +it lives only in the dropdown. + + + Checking out does not stash for you. A dirty tree that would be clobbered + makes git refuse the checkout, and tty7 shows you git's own refusal as a + notification rather than working around it. + When a repository is mid-operation — merging, rebasing, cherry-picking, reverting, bisecting, applying — the panel says so instead of pretending @@ -61,9 +73,12 @@ everything is normal. ## History -**Git: Toggle Commit History** (or the *History* section header) opens the -commit graph: branches drawn as lanes, a filter box, **Current Branch** or -**All Branches**, and *Load more* at the bottom. +The *History* section header opens the commit graph: branches drawn as lanes, a +filter box, **Current Branch** or **All Branches**, and *Load more* at the +bottom. + +**Git: Toggle Commit History** does the same from the keyboard. It ships with no +default key — bind one under **Settings → Keyboard Shortcuts**. Click a commit for its detail view — message, parents, and the files it touched, each openable as a diff. From a commit's menu: diff --git a/docs/reference/shell-integration.mdx b/docs/reference/shell-integration.mdx index 9e732d29..3f77bfbf 100644 --- a/docs/reference/shell-integration.mdx +++ b/docs/reference/shell-integration.mdx @@ -26,8 +26,8 @@ removes itself from the equation if you run the same shell elsewhere. injection when shells nest. - A shell launched with custom arguments is left alone for bash and PowerShell, - because tty7's injection would conflict with the flags you chose. + A shell launched with custom arguments is left alone for bash, PowerShell, and + WSL, because tty7's injection would conflict with the flags you chose. ## What it reports @@ -39,7 +39,7 @@ injection when shells nest. | Command finished, with exit code | `OSC 133;D` | The "finished after 42s" notification, failure marks in [history](/terminal/history) | | Working directory | `OSC 7` | New tabs and splits opening in the right place, the sidebar's repo grouping, the git branch readout | | Editing mode (vi / emacs) | `OSC 133;V` | Matching tty7's key handling to your shell's mode | -| Window title | `OSC 0` | Tab labels | +| Window title | `OSC 0` | Tab labels. Only PowerShell is given this — zsh, bash, and fish already set a title of their own, and tty7 reads whatever they emit | ## What turns off without it diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx index b23cbc95..99e1b1aa 100644 --- a/docs/remote/port-forwarding.mdx +++ b/docs/remote/port-forwarding.mdx @@ -38,7 +38,7 @@ the profile. -clicking a `localhost:PORT` link inside an SSH pane can open a temporary forward for exactly that port and then open the browser — turn on -**Settings → Input → Links → Forward SSH loopback links**. +**Settings → Terminal → Links → Forward SSH loopback links**. That is the right tool for "let me look at this dev server once". For something you use every day, put it in the profile. diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index ed44a8ab..fe632340 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -60,7 +60,7 @@ instead of a password echoing into your shell. | **Name** | A label for this connection | | **Host** | Hostname or IP | | **User** | Login user — blank resolves at connect time | -| **Auth** | *Auto* (tries every applicable method), *Password*, *Key*, *Agent*, or *2FA* | +| **Auth** | *Auto* (tries every applicable method), *GSSAPI*, *Password*, *Key*, *Agent*, or *2FA* | | **Jump host** | Another profile, or a `ProxyJump` chain | | **Port forwarding** | Rules opened with the connection | @@ -110,6 +110,7 @@ the current pane. Useful after a laptop sleeps or a network changes. ## What is not supported - No fallback to the system `ssh` binary -- No `Match`, `canonicalize*`, or GSSAPI directives from `~/.ssh/config` -- Kerberos `gssapi-with-mic` is offered by the desktop app for managed - connections, but is not part of the `~/.ssh/config` resolution path +- No `Match` or `canonicalize*` directives from `~/.ssh/config` +- No GSSAPI *directives* from `~/.ssh/config`. Kerberos `gssapi-with-mic` itself + is supported — pick **GSSAPI** in a profile's Auth field — it is just not + something the config-file resolution path reads diff --git a/docs/terminal/history.mdx b/docs/terminal/history.mdx index 4b3f1433..c3e875ec 100644 --- a/docs/terminal/history.mdx +++ b/docs/terminal/history.mdx @@ -8,23 +8,22 @@ description: "Fuzzy history search, and whether each pane gets its own." ⌃ R opens a fuzzy search over what you have actually run. Type any fragment — the letters do not have to be adjacent — and the list narrows. -Each row carries the context the plain shell version throws away: +Each row carries context the plain shell version throws away: -- **where** you ran it, so `npm run dev` from three repositories is three - distinct entries -- **when**, as a relative time +- **when** you last ran it, as a relative time - **whether it failed**, from the exit code -Ranking mixes frequency with recency, and commands you ran in the *current* -directory are pushed up — the thing you want is usually the thing you last did -here. +A command appears once, however many times you have run it — repeats collapse +into their most recent occurrence. Ranking mixes frequency with recency, and +commands you ran in the *current* directory are pushed up, so the thing you want +is usually the thing you last did here. - + Fuzzy history search - puts the command on the prompt. Esc closes without -touching it. + puts the command on the prompt. ⌘ ⏎ puts it there and +runs it. Esc closes without touching it. ### Handing ⌃ R back @@ -35,16 +34,19 @@ working. ## Where the history comes from -Your existing shell history file, as-is. Nothing is imported or converted, there -is no separate store to warm up, and a history written outside tty7 shows up -immediately. +Your existing shell history file, as-is. Nothing is imported or converted, and a +history written outside tty7 shows up immediately. + +The per-row extras — when you last ran it, whether it failed — come from a small +file tty7 keeps alongside it, filled in as you run things. A command tty7 has +never seen still appears; it just arrives without a timestamp or an exit code. ## One history, or one per pane By default every pane shares your shell's history file, which is what a terminal has always done: a command typed in one pane is available in the next. -**Settings → Terminal → Give each pane its own shell history** +**Settings → Input → Prompt → Give each pane its own shell history** (`per_pane_history: true`) changes that. Each pane gets a private history file: - **seeded** from your real history when the pane opens, so it is not blank diff --git a/docs/terminal/links.mdx b/docs/terminal/links.mdx index 0b8c3d34..5d937729 100644 --- a/docs/terminal/links.mdx +++ b/docs/terminal/links.mdx @@ -11,7 +11,7 @@ pointer underline; click to open one. Anything that looks like a URL is detected, including one the shell wrapped across two lines — tty7 stitches it back together before opening it. -Turn detection off with **Settings → Input → Links → Detect URLs** +Turn detection off with **Settings → Terminal → Links → Detect URLs** (`link_url: false`). ## Files @@ -19,7 +19,7 @@ Turn detection off with **Settings → Input → Links → Detect URLs** A file path in the output — a compiler error, a test failure, a `grep -n` hit — opens in your default application for that file type. -To send it somewhere specific instead, set **Settings → Input → Links → Open +To send it somewhere specific instead, set **Settings → Terminal → Links → Open files with**. The command runs with placeholders substituted: ``` @@ -41,9 +41,9 @@ The same setting is `link_file_command` in `config.json`. useful if the server is on this machine. When the pane is inside an SSH session it usually is not. Turn on **Settings → -Input → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`) and -tty7 opens a temporary port forward through that connection first, so the link -reaches the server on the remote machine. +Terminal → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`) +and tty7 opens a temporary port forward through that connection first, so the +link reaches the server on the remote machine. For a forward you want to keep, set one up properly instead — diff --git a/docs/terminal/prompt.mdx b/docs/terminal/prompt.mdx index e430c190..5e71f2d4 100644 --- a/docs/terminal/prompt.mdx +++ b/docs/terminal/prompt.mdx @@ -19,7 +19,7 @@ ahead of the cursor. |---|---| | | Accept the whole suggestion | | Keep typing | The suggestion narrows | -| Esc or anything that does not match | It disappears | +| Anything that does not match | It disappears | Your existing shell history is what feeds it — there is no separate database to build up first, and it carries across sessions and reboots. diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx index 154eb289..ffff9b38 100644 --- a/docs/window/command-palette.mdx +++ b/docs/window/command-palette.mdx @@ -23,7 +23,7 @@ Results are grouped, and the groups are the map of the app: | **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | | **Workspaces** | New Workspace · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | | **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | -| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Checkout to… · Create Branch… · Sync · Push · Pull · Fetch · Toggle Commit History | +| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | | **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All | | **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding | | **Agents** | Send Selection · Send Git Diff for Review · Copy Session ID | @@ -43,8 +43,9 @@ me@devbox:2222 [::1]:22 ``` -Saved profiles and `~/.ssh/config` aliases show up the same way — start typing -the name. [SSH →](/remote/ssh) +Saved profiles show up the same way — start typing the name. A host that only +exists in `~/.ssh/config` does not: import it into a profile first, or reach it +from the workspace switcher. [SSH →](/remote/ssh) ## Sending context to an agent diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index 11bacee7..d2f4258c 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -20,15 +20,16 @@ Everything tty7 knows about the focused pane, in one column: | **Processes** | the process tree inside the pane, with the foreground process marked | | **Ports** | every port those processes are listening on | -The working directory row has **Reveal in Finder** / **Open Folder** beside it. +On a local pane, the working directory row has **Reveal in Finder** / **Open +Folder** beside it. The ports section is the quickest answer to "what is this pane serving, and where" — the same data `tty7 procs` prints. ## Source Control -The git panel for the focused pane's repository: staged changes, unstaged -changes, untracked files, and merge conflicts, each in its own group. Write a -message, commit, and push without leaving the window. +The git panel for the focused pane's repository, in four groups — **Merge +Changes**, **Staged Changes**, **Changes**, **Untracked**. Write a message, +commit, and push without leaving the window. [Source control →](/git/source-control) diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index 9b49e427..ef9236e8 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -39,8 +39,9 @@ default) and *Flat*. [diff overlay](/git/diffs). - An unread marker on tabs that produced output while you were elsewhere. - *Mark as Unread* is in the right-click menu. + An unread marker on tabs where a coding agent finished its turn while you + were elsewhere. Agent tabs also carry *Mark as Unread* in the right-click + menu, once there is a finished turn to mark. @@ -50,21 +51,24 @@ they simply stop opening the overlay. ## Rearranging -Drag a row to reorder it, or drag a whole group header to move the group. -Dropping a row into another group moves the tab there. +Drag a row to reorder it within its group, or drag a whole group header to move +the group. A row cannot be dragged into a different group: with the default repo +grouping a tab's group comes from its working directory, so `cd` is what moves +it. ## Naming -Almost no tab has a name of its own, so the label falls back through the best -evidence available, in order: +Almost no tab has a name of its own, so the sidebar falls back: 1. a name you set (right-click → **Rename Tab…**) -2. the coding agent running in the tab — "Claude Code" -3. the last segment of the working directory -4. the foreground process +2. the title the shell is reporting — the running command, usually +3. **Shell 3**, numbered by position, when there is no title at all -That order is also what `tty7 tab ls` reports as `label`, with `name` left -literal so a script can tell a real name from a stand-in. +`tty7 tab ls` answers the same question with more evidence, because a script has +no screen to look at. Its `label` falls back through the name, then the coding +agent running in the tab ("Claude Code"), then the last segment of the working +directory, then the foreground process — while `name` stays literal, so a script +can tell a real name from a stand-in. ## The switcher diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index 423fe05d..e0095085 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -23,9 +23,10 @@ A new tab always opens in the current pane's directory. Where it lands in the list is **Settings → Window & Tabs → New tab position** — *After current* by default, or *At end*. -Right-click a tab for the rest: rename, close others, close to the right, copy -the working directory, mark unread, and — when a coding agent is running there — -fork its session. +Right-click a tab for the rest: rename, split right or down, a new worktree tab, +copy the working directory, copy the session id, close · close others · close to +the right, and — when a coding agent is running there — mark unread and fork its +session. ## Splits diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 06c6699b..ee5a49b0 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -191,7 +191,13 @@ fn build_spec_inner( let mut key_passphrases: HashMap = HashMap::new(); if matches!(profile.auth, AuthMode::Auto | AuthMode::PublicKey) { - for path in &identity_files { + // Explicit files, then the same `~/.ssh` defaults the daemon probes + // (#484): it looks passphrases up by the candidate string, so both + // sides must iterate the one shared list. + for path in identity_files + .iter() + .chain(crate::core::ssh_profile::default_identity_candidates().iter()) + { let Ok(bytes) = std::fs::read(path) else { continue; };