Merge branch 'main' into fix/ime-candidate-anchor

This commit is contained in:
l0ng-ai
2026-07-31 23:25:22 +08:00
committed by GitHub
8 changed files with 176 additions and 8 deletions
+1
View File
@@ -44,6 +44,7 @@ pub fn two_workspace_machine() -> Machine {
let record = |id: u64, cwd: &str| PaneRecord {
id,
cwd: Some(cwd.to_string()),
title: String::new(),
ssh_spec: None,
agent: None,
live: true,
+4
View File
@@ -258,6 +258,8 @@ pub struct PaneRecord {
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub ssh_spec: Option<Box<NativeSshSpec>>,
#[serde(default)]
pub agent: Option<AgentFacts>,
@@ -270,6 +272,7 @@ impl PaneRecord {
PaneRecord {
id,
cwd: None,
title: String::new(),
ssh_spec: None,
agent: None,
live: false,
@@ -313,6 +316,7 @@ impl PaneSeed {
PaneRecord {
id: self.pane,
cwd: self.cwd,
title: String::new(),
ssh_spec: self.ssh_spec.map(|s| Box::new(s.without_secrets())),
agent: self.agent,
live,
+43
View File
@@ -27,6 +27,37 @@ pub struct ShellInventory {
pub default_name: String,
}
/// Common interactive-shell process names, lowercase and without a
/// platform-specific extension. Anything that lands in here is what a pane
/// looks like at an idle prompt — not distinctive enough to stand in for a
/// pane's cwd/repo name when picking a display title.
const BARE_SHELL_NAMES: &[&str] = &[
"sh",
"bash",
"zsh",
"fish",
"dash",
"ksh",
"tcsh",
"csh",
"nu",
"elvish",
"xonsh",
"pwsh",
"powershell",
"cmd",
"wsl",
];
/// True when `name` is a bare interactive-shell process name (e.g. the
/// foreground process of an idle terminal), so callers that want a
/// *distinctive* title should skip it and fall back to something else.
pub fn is_bare_shell_name(name: &str) -> bool {
let lower = name.trim().to_ascii_lowercase();
let lower = lower.strip_suffix(".exe").unwrap_or(&lower);
BARE_SHELL_NAMES.contains(&lower)
}
pub fn inventory() -> ShellInventory {
let configured = crate::core::config::shell_command();
ShellInventory {
@@ -409,4 +440,16 @@ mod tests {
assert!(!default_shell_name(None).is_empty());
assert!(!default_shell_name(Some(" ")).is_empty());
}
#[test]
fn bare_shell_names_are_recognized_case_and_extension_insensitively() {
assert!(is_bare_shell_name("zsh"));
assert!(is_bare_shell_name("bash"));
assert!(is_bare_shell_name("PowerShell"));
assert!(is_bare_shell_name("pwsh.exe"));
assert!(is_bare_shell_name("CMD.EXE"));
assert!(!is_bare_shell_name("nvim"));
assert!(!is_bare_shell_name("claude"));
assert!(!is_bare_shell_name(""));
}
}
+4 -1
View File
@@ -1571,7 +1571,10 @@ fn agent_facts_changed(
match (before, after) {
(None, None) => false,
(Some(a), Some(b)) => {
a.agent != b.agent || a.session_id != b.session_id || a.launch_argv != b.launch_argv
a.agent != b.agent
|| a.session_id != b.session_id
|| a.launch_argv != b.launch_argv
|| a.status != b.status
}
_ => true,
}
+4
View File
@@ -84,6 +84,10 @@ impl crate::host::server::PaneDirectory for Registry {
self.panes.lock().unwrap().len() as u64
}
fn panes(&self) -> Vec<crate::daemon::protocol::PaneInfo> {
self.list()
}
fn agent_states(&self) -> Vec<crate::daemon::control::PaneAgentState> {
let panes: Vec<Arc<DaemonPane>> = self.panes.lock().unwrap().values().cloned().collect();
let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect();
+74 -4
View File
@@ -13,6 +13,7 @@ use crate::daemon::control::{
WireErrorKind, feature, server_started,
};
use crate::daemon::duplex::{Duplex, Halves};
use crate::daemon::protocol::PaneInfo;
use crate::host::{Host, SearchHit, SharedHost, WatchSub};
pub const MAX_WORKERS: usize = 64;
@@ -25,6 +26,7 @@ pub const LAYOUT_EVENT_QUEUE: usize = 1024;
pub trait PaneDirectory: Send + Sync {
fn pane_count(&self) -> u64;
fn panes(&self) -> Vec<PaneInfo>;
fn agent_states(&self) -> Vec<PaneAgentState>;
}
@@ -476,6 +478,26 @@ fn drop_unsendable_hits(hits: &mut Vec<SearchHit>) {
}
}
fn machine_with_live_panes(conn: &Conn) -> io::Result<machine::Machine> {
let mut machine = conn.machine()?.machine();
let Some(panes) = conn.panes.as_ref().map(|p| p.panes()) else {
return Ok(machine);
};
for info in panes {
let record = match machine.panes.iter_mut().find(|p| p.id == info.pane_id) {
Some(record) => record,
None => {
machine.panes.push(machine::PaneRecord::new(info.pane_id));
machine.panes.last_mut().expect("record was just inserted")
}
};
record.cwd = info.cwd.map(|p| p.to_string_lossy().into_owned());
record.title = info.title;
record.live = info.alive;
}
Ok(machine)
}
fn run_request(
conn: &Arc<Conn>,
req_id: u64,
@@ -590,7 +612,7 @@ fn run_request(
}
ControlRequest::MachineGet => (
ReplyOk::MachineTree(Box::new(conn.machine()?.machine())),
ReplyOk::MachineTree(Box::new(machine_with_live_panes(conn)?)),
Vec::new(),
),
ControlRequest::WorkspaceTree { workspace } => (
@@ -1535,13 +1557,19 @@ mod aggregate_tests {
use crate::host::local::LocalHost;
use std::net::{TcpListener, TcpStream};
struct ThreePanesOneAgent;
struct ThreePanesOneAgent {
panes: Vec<PaneInfo>,
}
impl PaneDirectory for ThreePanesOneAgent {
fn pane_count(&self) -> u64 {
3
}
fn panes(&self) -> Vec<PaneInfo> {
self.panes.clone()
}
fn agent_states(&self) -> Vec<PaneAgentState> {
vec![PaneAgentState {
pane_id: 7,
@@ -1574,7 +1602,7 @@ mod aggregate_tests {
#[test]
fn status_answers_with_this_servers_facts() {
let services = Services {
panes: Some(Arc::new(ThreePanesOneAgent)),
panes: Some(Arc::new(ThreePanesOneAgent { panes: Vec::new() })),
..Services::none()
};
let client = client_with(services);
@@ -1601,7 +1629,7 @@ mod aggregate_tests {
#[test]
fn agent_states_are_the_pane_directorys_snapshot() {
let services = Services {
panes: Some(Arc::new(ThreePanesOneAgent)),
panes: Some(Arc::new(ThreePanesOneAgent { panes: Vec::new() })),
..Services::none()
};
let client = client_with(services);
@@ -1616,6 +1644,48 @@ mod aggregate_tests {
assert_eq!(states[0].state.session_id.as_deref(), Some("sess-7"));
}
#[test]
fn machine_get_overlays_live_pane_titles() {
let dir = tempfile::TempDir::new().unwrap();
let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE));
let ws = store.workspace_create(None, None, None).unwrap();
store
.tab_create(
ws.id,
None,
machine::PaneSeed {
pane: 7,
cwd: Some("/repo/tty7".into()),
ssh_spec: None,
agent: None,
},
None,
None,
)
.unwrap();
let services = Services {
machine: Some(store),
attachments: Arc::new(AttachRegistry::default()),
panes: Some(Arc::new(ThreePanesOneAgent {
panes: vec![PaneInfo {
pane_id: 7,
cwd: Some(PathBuf::from("/repo/tty7")),
title: "nvim".into(),
alive: true,
owner: None,
}],
})),
};
let client = client_with(services);
let ReplyOk::MachineTree(machine) = client.call(ControlRequest::MachineGet).unwrap() else {
panic!("MachineGet must answer with a machine tree");
};
let pane = machine.panes.iter().find(|p| p.id == 7).unwrap();
assert_eq!(pane.title, "nvim");
assert!(pane.live);
}
#[test]
fn aggregates_still_answer_when_this_process_serves_no_panes() {
let client = client_with(Services::none());
+28 -2
View File
@@ -257,6 +257,9 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
return name.to_string();
}
if let Some(title) = pane_title_of(ws, panes) {
return title.to_string();
}
subject_path_of(ws, panes)
.and_then(|path| {
std::path::Path::new(&path)
@@ -267,6 +270,15 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
.unwrap_or_else(|| "Untitled".to_string())
}
fn pane_title_of<'a>(ws: &Workspace, panes: &'a [PaneRecord]) -> Option<&'a str> {
ws.tabs
.iter()
.flat_map(|t| t.root.pane_ids())
.filter_map(|id| panes.iter().find(|p| p.id == id))
.map(|p| p.title.trim())
.find(|title| !title.is_empty() && !tty7_core::core::shells::is_bare_shell_name(title))
}
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
let mut counts: Vec<(&str, usize)> = Vec::new();
for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) {
@@ -501,15 +513,29 @@ mod tests {
#[test]
fn display_names_derive_from_the_tree_with_the_session_precedence() {
let mut ws = Workspace::default();
let panes = vec![PaneRecord {
let mut panes = vec![PaneRecord {
cwd: Some("/home/me/scratch".into()),
..PaneRecord::new(1)
}];
ws.tabs = vec![leaf_tab(1)];
assert_eq!(display_name_of(&ws, &panes), "scratch");
panes[0].title = "nvim".into();
assert_eq!(display_name_of(&ws, &panes), "nvim");
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
assert_eq!(display_name_of(&ws, &panes), "tty7");
assert_eq!(
display_name_of(&ws, &panes),
"nvim",
"a live process name is more distinctive than the cwd group"
);
panes[0].title = "zsh".into();
assert_eq!(
display_name_of(&ws, &panes),
"tty7",
"an idle shell prompt is not distinctive — cwd/repo name should win"
);
ws.name = Some(" Release prep ".into());
assert_eq!(display_name_of(&ws, &panes), "Release prep");
+18 -1
View File
@@ -232,7 +232,11 @@ impl Tty7App {
}
crate::terminal::pane_liveness::sweep(cx);
let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace)
let current = self
.tabs
.get(self.active)
.and_then(|tab| workspace_osc_title(tab, cx).or_else(|| workspace_agent_title(tab, cx)))
.or_else(|| crate::ui::machine_mirror::display_name_for(cx, self.workspace))
.unwrap_or_else(|| "tty7".to_string());
let monogram: String = current
.chars()
@@ -1082,6 +1086,19 @@ impl Tty7App {
}
}
fn workspace_osc_title(tab: &Tab, cx: &App) -> Option<String> {
let title = tab.leaf_title(None, cx);
let title = title.trim();
if title.is_empty() || title == "tty7" || title.starts_with("tty7 — ") {
return None;
}
Some(title.to_string())
}
fn workspace_agent_title(tab: &Tab, cx: &App) -> Option<String> {
tab.agent(cx).map(|agent| agent.display_name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;