Files
tty7/crates/tty7-cli/src/address.rs
T
l0ng-ai 8131ac2f93 Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
2026-08-20 18:28:48 +08:00

276 lines
9.6 KiB
Rust

use anyhow::{Result, anyhow, bail};
use tty7_core::core::session::WorkspaceId;
pub const ENV_PANE: &str = "TTY7_PANE";
pub const ENV_WS: &str = "TTY7_WS";
/// The server's config dir, not a socket path — the server publishes the
/// directory so both endpoints resolve through tty7_core's own derivation
/// rather than a second one here. Inherited by this process, so
/// `ControlClient::connect` / `PaneClient::local` already land on the right
/// server without the CLI touching a path at all.
pub const ENV_CONFIG_DIR: &str = "TTY7_CONFIG_DIR";
pub const OUTSIDE_SHELL: &str = "not inside a tty7 shell — pass an explicit %pane/@tab/workspace";
#[derive(Debug, Clone, Default)]
pub struct Context {
pub pane: Option<String>,
pub ws: Option<String>,
pub config_dir: Option<String>,
}
impl Context {
pub fn from_env() -> Context {
Context {
pane: std::env::var(ENV_PANE).ok().filter(|v| !v.is_empty()),
ws: std::env::var(ENV_WS).ok().filter(|v| !v.is_empty()),
config_dir: std::env::var(ENV_CONFIG_DIR).ok().filter(|v| !v.is_empty()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TabAddress {
Ordinal(u64),
Id(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum WorkspaceAddress {
Id(WorkspaceId),
Named(String),
}
pub fn parse_pane(s: &str) -> Result<u64> {
// The `%` is optional: `tty7 pane ls --json` hands back bare ids, so a
// copied `83` must address the same pane as `%83` — refusing it sent
// people to workarounds uglier than the typo this guard exists to catch
// (#538). `pane_from_env` already read both shapes; this aligns the
// explicit slot with it.
let digits = s.strip_prefix('%').unwrap_or(s);
let not_an_address = || anyhow!("'{s}' is not a pane address — panes look like %42");
// Digits and nothing else. `u64::from_str` also accepts a leading `+`, and
// now that the `%` is optional that would quietly turn a `send +5` meant as
// text into a keystroke aimed at pane 5.
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(not_an_address());
}
// Still parsed, not just counted: an id past u64 is no pane either.
digits.parse().map_err(|_| not_an_address())
}
pub fn parse_tab(s: &str) -> Result<TabAddress> {
// The `@` is optional, for the reason it is optional on a pane (#538): every
// `--json` payload spells tabs bare, so the id `tty7 tab new --json` just
// handed back has to address the tab it created. Demanding the sigil made
// the one id you are certain of the one shape the CLI refused.
let body = s.strip_prefix('@').unwrap_or(s);
let not_an_address =
|| anyhow!("'{s}' is not a tab address — @7 as numbered by `tty7 ls`, or a full tab id");
if body.is_empty() {
return Err(not_an_address());
}
// Digits and nothing else. `u64::from_str` also takes a leading `+`, and
// with the `@` gone that would read `+5` as tab 5 rather than as a typo.
if body.bytes().all(|b| b.is_ascii_digit()) {
return body
.parse()
.map(TabAddress::Ordinal)
.map_err(|_| not_an_address());
}
if looks_like_uuid(body) {
return Ok(TabAddress::Id(body.to_string()));
}
Err(not_an_address())
}
fn looks_like_uuid(s: &str) -> bool {
s.len() == 36
&& s.char_indices().all(|(i, c)| match i {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
})
}
pub fn parse_workspace(s: &str) -> WorkspaceAddress {
match s.parse::<WorkspaceId>() {
Ok(id) => WorkspaceAddress::Id(id),
Err(_) => WorkspaceAddress::Named(s.to_string()),
}
}
pub fn pane_or_context(explicit: Option<&str>, ctx: &Context) -> Result<u64> {
if let Some(s) = explicit {
return parse_pane(s);
}
match &ctx.pane {
Some(v) => pane_from_env(v),
None => bail!(OUTSIDE_SHELL),
}
}
fn pane_from_env(v: &str) -> Result<u64> {
// The same read as parse_pane, delegated rather than repeated so the two
// cannot drift; only the error differs, because "pass %pane" cannot fix an
// inherited env value.
parse_pane(v).map_err(|_| {
anyhow!("{ENV_PANE}='{v}' is not a pane id; unset it or pass %pane explicitly")
})
}
pub fn workspace_or_context(explicit: Option<&str>, ctx: &Context) -> Result<WorkspaceAddress> {
if let Some(s) = explicit {
return Ok(parse_workspace(s));
}
match &ctx.ws {
Some(v) => Ok(parse_workspace(v)),
None => bail!(OUTSIDE_SHELL),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn shell_context() -> Context {
Context {
pane: Some("42".into()),
ws: Some("0d4e1a54-0000-4000-8000-000000000001".into()),
config_dir: Some("C:\\Users\\me\\AppData\\Roaming\\tty7".into()),
}
}
#[test]
fn the_three_address_shapes_parse_apart() {
assert_eq!(parse_pane("%42").unwrap(), 42);
assert_eq!(parse_tab("@7").unwrap(), TabAddress::Ordinal(7));
assert_eq!(
parse_workspace("api"),
WorkspaceAddress::Named("api".into())
);
let id = "0d4e1a54-0000-4000-8000-000000000001";
assert_eq!(
parse_workspace(id),
WorkspaceAddress::Id(id.parse().unwrap())
);
}
#[test]
fn a_full_tab_id_is_also_an_address() {
let id = "0d4e1a54-0000-4000-8000-000000000002";
assert_eq!(
parse_tab(&format!("@{id}")).unwrap(),
TabAddress::Id(id.into())
);
}
#[test]
fn a_bare_pane_id_addresses_the_same_pane_as_the_marked_one() {
// `tty7 pane ls --json` prints bare ids; refusing them here pushed
// people into "%${TTY7_PANE#%}" contortions (#538).
assert_eq!(parse_pane("42").unwrap(), 42);
assert_eq!(parse_pane("%42").unwrap(), 42);
}
#[test]
fn a_bare_tab_id_addresses_the_same_tab_as_the_marked_one() {
// Every `--json` payload spells tabs bare, and the id `tty7 tab new`
// hands back is the one tab you are certain of — refusing it there made
// naming a tab you just created impossible without counting `@N` again.
let id = "0d4e1a54-0000-4000-8000-000000000003";
assert_eq!(parse_tab(id).unwrap(), TabAddress::Id(id.into()));
assert_eq!(
parse_tab(&format!("@{id}")).unwrap(),
TabAddress::Id(id.into())
);
assert_eq!(parse_tab("7").unwrap(), TabAddress::Ordinal(7));
assert_eq!(parse_tab("@7").unwrap(), TabAddress::Ordinal(7));
}
#[test]
fn a_tab_ordinal_is_digits_and_nothing_else() {
// Same guard as the pane one, and it matters for the same reason now
// that the sigil is optional on both.
for not_a_tab in ["+5", "@+5", "-5", " 5", "5 ", "", "@", "5.0", "build"] {
assert!(
parse_tab(not_a_tab).is_err(),
"'{not_a_tab}' must not read as a tab address"
);
}
assert!(parse_tab("99999999999999999999999").is_err());
}
#[test]
fn an_address_is_digits_and_nothing_else() {
// `u64::from_str` takes a leading `+`; an address must not, or a bare
// `+5` handed to `send` as text would address pane 5 instead (#538).
for not_a_pane in ["+5", "%+5", "-5", " 5", "5 ", "", "%", "5.0"] {
assert!(
parse_pane(not_a_pane).is_err(),
"'{not_a_pane}' must not read as an address"
);
}
// Past u64 is no pane either, however digit-shaped.
assert!(parse_pane("99999999999999999999999").is_err());
}
#[test]
fn malformed_addresses_say_what_they_should_look_like() {
let err = parse_pane("abc").unwrap_err().to_string();
assert!(err.contains("%42"), "the fix is shown: {err}");
let err = parse_pane("%abc").unwrap_err().to_string();
assert!(err.contains("%42"), "{err}");
let err = parse_tab("@build").unwrap_err().to_string();
assert!(err.contains("@7"), "{err}");
}
#[test]
fn omitted_addresses_fall_back_to_the_injected_context() {
let ctx = shell_context();
assert_eq!(pane_or_context(None, &ctx).unwrap(), 42);
assert_eq!(
workspace_or_context(None, &ctx).unwrap(),
WorkspaceAddress::Id("0d4e1a54-0000-4000-8000-000000000001".parse().unwrap())
);
}
#[test]
fn an_explicit_address_beats_the_context() {
let ctx = shell_context();
assert_eq!(pane_or_context(Some("%7"), &ctx).unwrap(), 7);
}
#[test]
fn env_pane_values_may_carry_the_percent_or_not() {
let bare = Context {
pane: Some("42".into()),
..Context::default()
};
let marked = Context {
pane: Some("%42".into()),
..Context::default()
};
assert_eq!(pane_or_context(None, &bare).unwrap(), 42);
assert_eq!(pane_or_context(None, &marked).unwrap(), 42);
}
#[test]
fn outside_a_tty7_shell_the_error_names_the_fix() {
let ctx = Context::default();
let err = pane_or_context(None, &ctx).unwrap_err().to_string();
assert_eq!(err, OUTSIDE_SHELL);
let err = workspace_or_context(None, &ctx).unwrap_err().to_string();
assert_eq!(err, OUTSIDE_SHELL);
}
#[test]
fn a_broken_env_pane_is_reported_not_silently_ignored() {
let ctx = Context {
pane: Some("not-a-pane".into()),
..Context::default()
};
let err = pane_or_context(None, &ctx).unwrap_err().to_string();
assert!(err.contains("TTY7_PANE"), "{err}");
}
}