fix(cli): name a mistyped subcommand instead of offering it to the GUI

`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)".
Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands
in that positional — every typo came back as an offer to open a directory named
after it.

A word with no separator, no leading ./~, and no matching file on disk is a
mistyped verb, and is now reported as one. Real paths still reach the launcher
and fail there for the honest reason.

Also: `ws`'s help said "the named session trees", missed by the session -> shell
pass, and long_about still advertised $TTY7_SOCKET, which no longer exists —
it is $TTY7_CONFIG_DIR now.
This commit is contained in:
l0ng-ai
2026-07-31 15:07:03 +08:00
parent a94dac3fca
commit 95ceed533d
3 changed files with 49 additions and 60 deletions
+4 -3
View File
@@ -12,8 +12,9 @@ use clap::{ArgGroup, Args, Parser, Subcommand};
agents: every verb is non-interactive, and --json makes the output\n\
machine-readable.\n\n\
An agent reaches the server through this binary, not through a wire\n\
protocol of its own. Inside a tty7 shell, $TTY7_PANE / $TTY7_WS /\n\
$TTY7_SOCKET let the address-taking verbs run with no address given."
protocol of its own. Inside a tty7 shell, $TTY7_CONFIG_DIR points at\n\
the server, and $TTY7_PANE / $TTY7_WS let the address-taking verbs run\n\
with no address given."
)]
pub struct Cli {
#[arg(
@@ -93,7 +94,7 @@ pub enum Command {
#[command(
subcommand,
about = "Workspaces: the named session trees the server keeps alive"
about = "Workspaces: the named trees of tabs and panes the server keeps alive"
)]
Ws(WsCmd),
+45 -1
View File
@@ -42,7 +42,18 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
let json_mode = cli.json;
let machine = cli.machine.clone();
match cli.command {
None => launch_gui(cli.path),
None => match cli.path {
// clap has no subcommand for this word, so it landed in [PATH].
// Treat a word that is not a path as the typo it almost certainly
// is: answering "launching the GUI is not wired up yet" to
// `tty7 statu` helps nobody.
Some(word) if !looks_like_a_path(&word) => bail!(
"unknown subcommand '{word}' — run `tty7 --help` for the list. \
(A path in this position would open the GUI there, but \
'{word}' does not name one.)"
),
path => launch_gui(path),
},
Some(Command::Ls) | Some(Command::Ws(WsCmd::Ls)) => ws_ls(backend),
Some(Command::Ws(WsCmd::Tree { ws })) => ws_tree(ws.as_deref(), ctx, backend),
Some(Command::Ws(WsCmd::New { name })) => ws_new(name, backend),
@@ -113,6 +124,20 @@ fn local_server(
act()
}
/// Whether a bare word in the `[PATH]` position was meant as a path.
///
/// Anything with a separator, a leading `.`/`~`, or that actually exists on
/// disk counts. A plain word like `tree` or `statu` does not — it is a
/// mistyped subcommand, and saying so beats offering to open the GUI there.
fn looks_like_a_path(s: &str) -> bool {
s.starts_with('/')
|| s.starts_with('.')
|| s.starts_with('~')
|| s.contains('/')
|| s.contains('\\')
|| std::path::Path::new(s).exists()
}
fn launch_gui(path: Option<String>) -> Result<Outcome> {
match path {
Some(p) => bail!("launching the GUI is not wired up yet (would open {p})"),
@@ -1403,6 +1428,25 @@ mod tests {
}
}
#[test]
fn a_mistyped_subcommand_is_named_as_one_not_offered_to_the_gui() {
for typo in ["tree", "statu", "pnae", "workspace"] {
let err = execute(cli(&["tty7", typo]), &Context::default(), &mut mock())
.expect_err("a bare word is not a path");
let msg = err.to_string();
assert!(msg.contains("unknown subcommand"), "{typo}: {msg}");
assert!(msg.contains(typo), "{typo}: {msg}");
}
// Things that do look like paths still reach the GUI launcher, and fail
// there for the honest reason.
for path in ["/tmp", "./src", "~/proj", "a/b"] {
let err = execute(cli(&["tty7", path]), &Context::default(), &mut mock())
.expect_err("the GUI launcher is not implemented yet");
assert!(err.to_string().contains("not wired up"), "{path}: {err}");
}
}
#[test]
fn the_still_missing_verbs_say_so_without_touching_the_wire() {
for (args, needle) in [
-56
View File
@@ -4481,61 +4481,6 @@ impl Tty7App {
.into_any_element()
}
fn render_session_model(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let entry = |term: &'static str, meaning: &'static str| {
v_flex()
.gap_0p5()
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child(term),
)
.child(div().text_xs().text_color(muted_fg).child(meaning))
};
v_flex()
.mt_6()
.gap_2()
.child(self.section_rule(cx))
.child(
div()
.text_sm()
.font_weight(FontWeight::MEDIUM)
.text_color(foreground)
.child("How shells work"),
)
.child(div().text_xs().text_color(muted_fg).child(
"Your shells run in tty7's background server, not in this window. That is what lets them outlive a quit or a reboot — and it means \"close\" and \"end\" are different things here.",
))
.child(
v_flex()
.mt_2()
.gap_3()
.child(entry(
"Closing a window (⌘W on the last tab)",
"Detaches the workspace. Every shell keeps running; the workspace waits on the home page and in the title-bar menu.",
))
.child(entry(
"Quitting tty7 (⌘Q)",
"Same deal, for every window. Nothing running is interrupted.",
))
.child(entry(
"Stop Workspace",
"Ends that workspace's shells but keeps its layout, so you can start it again with fresh ones.",
))
.child(entry(
"Delete Workspace",
"Ends the shells and forgets the layout. The only step here you can't undo.",
)),
)
.into_any_element()
}
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
@@ -4599,7 +4544,6 @@ impl Tty7App {
.child("Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty"),
),
)
.child(self.render_session_model(cx))
.child(
v_flex()
.mt_6()