mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(cli): stamp panes with the workspace that holds them, not the client's name (#425)
* fix(cli): stamp panes with the workspace that holds them, not the client's name A pane's owner names the workspace allowed to attach to it. The CLI wrote a literal "tty7-cli" there for every pane it made, so a window opening on a CLI-built workspace found none of them attachable: it spawned a fresh shell for each tab, orphaned the live ones, and — because the tree still carried each pane's agent session — greeted the user with a failing `claude --resume <id>` in every one of them. Both spawn paths now pass the workspace id, and restore treats an owner that parses as no workspace as no claim at all, so panes already stamped by an older CLI attach instead of stranding. * fix(cli): let the OWNER column speak only when it disagrees with WS Now that a pane's owner is the id of the workspace holding it, printing both spells the same id twice on every row of `pane ls --all` — and buries the rows that matter. The column now shows a dash when the two agree, so what is left is exactly what is worth reading: a pane its holder may not attach to, and an orphan still naming where it belongs. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
@@ -124,14 +124,15 @@ impl Backend for RealBackend {
|
||||
}
|
||||
|
||||
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64> {
|
||||
let workspace = workspace.to_string();
|
||||
let session = self
|
||||
.pane_client()?
|
||||
.spawn(
|
||||
cwd.map(PathBuf::from),
|
||||
SESSION_SIZE,
|
||||
None,
|
||||
Some("tty7-cli".into()),
|
||||
Some(workspace.to_string()),
|
||||
Some(workspace.clone()),
|
||||
Some(workspace),
|
||||
)
|
||||
.context("spawning a shell")?;
|
||||
let pane = session.pane_id();
|
||||
@@ -230,14 +231,18 @@ impl Backend for RealBackend {
|
||||
args: args.to_vec(),
|
||||
args_are_tty7_defaults: false,
|
||||
};
|
||||
// A `run` with no workspace is nobody's pane, so it is left unowned
|
||||
// rather than stamped: an owner names the workspace that may attach to
|
||||
// it, and there is none until `--keep` files it into a tab.
|
||||
let workspace = spec.workspace.map(|ws| ws.to_string());
|
||||
let session = self
|
||||
.pane_client()?
|
||||
.spawn(
|
||||
spec.cwd.map(PathBuf::from),
|
||||
SESSION_SIZE,
|
||||
Some(shell),
|
||||
Some("tty7-cli".into()),
|
||||
spec.workspace.map(|ws| ws.to_string()),
|
||||
workspace.clone(),
|
||||
workspace,
|
||||
)
|
||||
.with_context(|| format!("spawning `{program}`"))?;
|
||||
let pane = session.pane_id();
|
||||
|
||||
@@ -136,19 +136,32 @@ pub fn pane_table(machine: &Machine, only: Option<WorkspaceId>) -> String {
|
||||
/// The server's registry rather than the machine tree, so orphans appear. `held`
|
||||
/// answers which workspace holds a pane, if any; a pane with no holder is shown
|
||||
/// as `-` under WS, which is the whole point of the listing.
|
||||
///
|
||||
/// OWNER names the workspace allowed to attach to the pane, and for a pane in
|
||||
/// its own workspace's tree that is the WS beside it — so the column only
|
||||
/// speaks when the two disagree, which is the case worth reading: an orphan
|
||||
/// still remembering where it belongs, or a pane the holder cannot attach to.
|
||||
pub fn registry_table(panes: &[PaneInfo], held: &dyn Fn(u64) -> Option<String>) -> String {
|
||||
if panes.is_empty() {
|
||||
return "no panes\n".to_string();
|
||||
}
|
||||
let short = |id: &str| -> String { id.chars().take(8).collect() };
|
||||
let rows: Vec<Vec<String>> = panes
|
||||
.iter()
|
||||
.map(|info| {
|
||||
let holder = held(info.pane_id);
|
||||
let owner = match (&info.owner, &holder) {
|
||||
(None, _) => "-".to_string(),
|
||||
(Some(owner), Some(holder)) if owner == holder => "-".to_string(),
|
||||
(Some(owner), _) => short(owner),
|
||||
};
|
||||
vec![
|
||||
format!("%{}", info.pane_id),
|
||||
held(info.pane_id)
|
||||
.map(|ws| ws.chars().take(8).collect())
|
||||
holder
|
||||
.as_deref()
|
||||
.map(short)
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
info.owner.clone().unwrap_or_else(|| "-".to_string()),
|
||||
owner,
|
||||
info.cwd
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
@@ -307,6 +320,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_speaks_only_when_it_disagrees_with_the_workspace_holding_the_pane() {
|
||||
let held = "9fd8072f-465c-4016-9a81-8143bff1240c";
|
||||
let elsewhere = "76698a44-3f13-4961-8fed-90d0b3defff1";
|
||||
let pane = |id: u64, owner: Option<&str>| PaneInfo {
|
||||
pane_id: id,
|
||||
cwd: None,
|
||||
title: "zsh".into(),
|
||||
alive: true,
|
||||
owner: owner.map(str::to_string),
|
||||
};
|
||||
let rendered = registry_table(
|
||||
&[
|
||||
pane(1, Some(held)),
|
||||
pane(2, Some(elsewhere)),
|
||||
pane(3, None),
|
||||
pane(4, Some(elsewhere)),
|
||||
],
|
||||
&|id| (id != 4).then(|| held.to_string()),
|
||||
);
|
||||
|
||||
let owner_of = |pane: &str| -> String {
|
||||
rendered
|
||||
.lines()
|
||||
.find(|line| line.starts_with(pane))
|
||||
.unwrap_or_else(|| panic!("{pane} is listed: {rendered}"))
|
||||
.split_whitespace()
|
||||
.nth(2)
|
||||
.expect("PANE WS OWNER")
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(
|
||||
owner_of("%1"),
|
||||
"-",
|
||||
"repeating the WS beside it says nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
owner_of("%2"),
|
||||
"76698a44",
|
||||
"a holder that may not attach is the whole reason to look"
|
||||
);
|
||||
assert_eq!(owner_of("%3"), "-", "nobody claims it");
|
||||
assert_eq!(
|
||||
owner_of("%4"),
|
||||
"76698a44",
|
||||
"an orphan still remembers where it belongs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_characters_are_padded_by_display_width_not_byte_length() {
|
||||
// "项目" is 6 bytes but occupies 4 columns. Padding by len() would add
|
||||
|
||||
@@ -38,6 +38,10 @@ fn main() {
|
||||
"tab_close_terminates_every_pane_in_the_tab",
|
||||
tab_close_terminates_every_pane_in_the_tab,
|
||||
),
|
||||
(
|
||||
"every_pane_the_cli_files_names_its_workspace_as_owner",
|
||||
every_pane_the_cli_files_names_its_workspace_as_owner,
|
||||
),
|
||||
(
|
||||
"run_streams_output_and_passes_the_exit_code",
|
||||
run_streams_output_and_passes_the_exit_code,
|
||||
@@ -430,6 +434,47 @@ fn tab_close_terminates_every_pane_in_the_tab(daemon: &Daemon) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pane's owner is the workspace allowed to attach to it, and the GUI
|
||||
/// respawns over anything else — so every way the CLI makes a pane has to
|
||||
/// stamp that id, not a name of its own. It used to write a literal
|
||||
/// "tty7-cli", which left a CLI-built workspace rebuilt from scratch the
|
||||
/// first time a window opened on it: fresh shells, the live ones orphaned.
|
||||
fn every_pane_the_cli_files_names_its_workspace_as_owner(daemon: &Daemon) {
|
||||
let created = daemon.run_json(&["new", &workdir()]);
|
||||
let ws_id = created["id"]
|
||||
.as_str()
|
||||
.expect("new prints the workspace id")
|
||||
.to_string();
|
||||
|
||||
let tab = daemon.run_json(&["tab", "new", &ws_id, "--cwd", &workdir()]);
|
||||
let tabbed = tab["pane"].as_u64().expect("tab new prints the pane id");
|
||||
daemon.run_json(&["split", &format!("%{tabbed}"), "--horizontal"]);
|
||||
|
||||
let listed = daemon.run_json(&["pane", "ls", "--all"]);
|
||||
let panes = listed["panes"]
|
||||
.as_array()
|
||||
.expect("pane ls --all prints the daemon registry");
|
||||
let ours: Vec<&serde_json::Value> = panes
|
||||
.iter()
|
||||
.filter(|p| p["workspace"].as_str() == Some(ws_id.as_str()))
|
||||
.collect();
|
||||
// `run --keep` files a pane the same way, but its command has to exit for
|
||||
// the CLI to return, and the registry drops the pane with it — so the
|
||||
// three that outlive their command are what can be read back here.
|
||||
assert_eq!(
|
||||
ours.len(),
|
||||
3,
|
||||
"new, tab new and split each filed one pane: {listed}"
|
||||
);
|
||||
for pane in ours {
|
||||
assert_eq!(
|
||||
pane["owner"].as_str(),
|
||||
Some(ws_id.as_str()),
|
||||
"a pane its workspace holds must name that workspace as owner: {pane}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_streams_output_and_passes_the_exit_code(daemon: &Daemon) {
|
||||
let echo = one_shot("echo tty7_e2e_run_marker");
|
||||
let mut args: Vec<&str> = vec!["run", "--"];
|
||||
|
||||
+15
-1
@@ -6359,7 +6359,15 @@ fn pane_attachable(
|
||||
None => false,
|
||||
Some(None) => true,
|
||||
Some(Some(recorded)) => {
|
||||
let ours = *recorded == owner.to_string();
|
||||
// Only a workspace id is a claim. Anything else is a client
|
||||
// stamping its own name — `tty7` before this release wrote a
|
||||
// literal "tty7-cli" — and refusing on it strands every pane the
|
||||
// CLI ever made, respawning over a live shell the tree just told
|
||||
// us belongs here.
|
||||
let Ok(recorded) = recorded.parse::<crate::core::session::WorkspaceId>() else {
|
||||
return true;
|
||||
};
|
||||
let ours = recorded == owner;
|
||||
if !ours {
|
||||
log::warn!(
|
||||
"restore: pane {id} is owned by workspace {recorded}, not {owner}; \
|
||||
@@ -7170,6 +7178,7 @@ mod tests {
|
||||
(1, Some(ours.to_string())),
|
||||
(2, Some(theirs.to_string())),
|
||||
(3, None),
|
||||
(5, Some("tty7-cli".to_string())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -7186,6 +7195,11 @@ mod tests {
|
||||
pane_attachable(Some(&alive), 3, ours),
|
||||
"an unowned pane is legacy"
|
||||
);
|
||||
assert!(
|
||||
pane_attachable(Some(&alive), 5, ours),
|
||||
"an owner that names no workspace is not a rival's claim: older CLIs \
|
||||
wrote their own name there, and respawning strands the live pane"
|
||||
);
|
||||
assert!(
|
||||
!pane_attachable(Some(&alive), 4, ours),
|
||||
"a dead id never attaches"
|
||||
|
||||
Reference in New Issue
Block a user