diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index dac940b0..f25c36a7 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -310,6 +310,47 @@ impl CLIAgent { } } + /// A fresh start under `session_id`, for when [`Self::resume_command`] + /// finds nothing: Claude saves no conversation for a session that never + /// took a turn (or ran with transcript saving off), and resuming one + /// fails at once. `None` for agents that cannot name a new session. + pub fn start_command(self, session_id: &str, launch_argv: Option<&[String]>) -> Option { + if self != CLIAgent::Claude || uuid::Uuid::parse_str(session_id).is_err() { + return None; + } + let flags = self.session_command_flags(session_id, launch_argv)?; + Some(format!("claude{flags} --session-id {session_id}")) + } + + /// The line a restored pane types to bring its agent back: the resume, + /// falling back to [`Self::start_command`] with `||` when the pane's shell + /// has that operator. `shell_program` is that shell, `None` when it is not + /// known — then, as in a shell without `||`, the resume goes alone, since + /// one that cannot parse the line would not run the resume either. + /// + /// `||` fires on any failed exit, not only "no conversation found". The + /// other ways a resume fails fast (a flag this version rejects, no login) + /// fail the fresh start the same way; one that exits non-zero after the + /// user took turns follows with a start under an id that now has a + /// conversation, which Claude turns down as already in use. Telling the + /// cases apart would mean reading Claude's stderr, which a typed line + /// cannot do portably. + pub fn restore_command( + self, + session_id: &str, + launch_argv: Option<&[String]>, + shell_program: Option<&str>, + ) -> Option { + let resume = self.resume_command(session_id, launch_argv)?; + let fresh = shell_program + .filter(|shell| crate::core::shell_quote::runs_or_list(shell)) + .and_then(|_| self.start_command(session_id, launch_argv)); + Some(match fresh { + Some(fresh) => format!("{resume} || {fresh}"), + None => resume, + }) + } + fn session_command_flags( self, session_id: &str, @@ -1163,6 +1204,43 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { mod tests { use super::*; + #[test] + fn claude_starts_fresh_under_the_same_id_with_its_flags() { + const ID: &str = "0b5c3a5e-6d0e-4c1f-9a4b-2f7f1d9e8c11"; + let launched = argv(&["claude", "--model", "opus", "--session-id", ID]); + assert_eq!( + CLIAgent::Claude + .start_command(ID, Some(&launched)) + .as_deref(), + Some(format!("claude --model opus --session-id {ID}").as_str()) + ); + assert_eq!(CLIAgent::Claude.start_command("not-a-uuid", None), None); + assert_eq!(CLIAgent::Codex.start_command(ID, None), None); + } + + #[test] + fn a_restore_falls_back_to_a_fresh_start_only_where_the_shell_has_or() { + const ID: &str = "0b5c3a5e-6d0e-4c1f-9a4b-2f7f1d9e8c11"; + let launched = argv(&["claude", "--model", "opus"]); + let resume = CLIAgent::Claude + .resume_command(ID, Some(&launched)) + .unwrap(); + let restore = |shell| CLIAgent::Claude.restore_command(ID, Some(&launched), shell); + let chained = format!("{resume} || claude --model opus --session-id {ID}"); + assert_eq!(restore(Some("/bin/zsh")).as_deref(), Some(chained.as_str())); + assert_eq!(restore(Some("pwsh.exe")).as_deref(), Some(chained.as_str())); + // Windows PowerShell 5.1 and nu reject the whole line over `||`, and a + // shell nobody could name might be either. + assert_eq!(restore(Some("powershell.exe")), Some(resume.clone())); + assert_eq!(restore(Some("nu")), Some(resume.clone())); + assert_eq!(restore(None), Some(resume)); + // Agents with no way to name a new session keep the bare resume. + assert_eq!( + CLIAgent::Codex.restore_command(ID, None, Some("bash")), + CLIAgent::Codex.resume_command(ID, None) + ); + } + fn argv(parts: &[&str]) -> Vec { parts.iter().map(|s| s.to_string()).collect() } diff --git a/crates/tty7-core/src/core/shell_quote.rs b/crates/tty7-core/src/core/shell_quote.rs index 555fd117..25f0268e 100644 --- a/crates/tty7-core/src/core/shell_quote.rs +++ b/crates/tty7-core/src/core/shell_quote.rs @@ -78,6 +78,24 @@ pub fn quoting_for(shell_program: Option<&str>) -> Quoting { } } +/// Whether `a || b` runs `b` only when `a` fails, in the shell named by +/// `shell_program` (a binary as `ShellSpec::program` reports it). +/// +/// An allowlist, because the shells that lack it do not skip the operator — +/// they refuse the whole line, `a` included. Windows PowerShell 5.1 calls it an +/// invalid statement separator (pwsh 7 added it), nu and elvish have no such +/// operator, and fish only gained it in 3.0 — anything older is long past its +/// end of life, so a bare `fish` counts. `wsl` is out: which shell it lands in +/// is the distro's business. +pub fn runs_or_list(shell_program: &str) -> bool { + const OR_LIST: &[&str] = &[ + "sh", "bash", "zsh", "dash", "ash", "ksh", "mksh", "oksh", "yash", "fish", "pwsh", "cmd", + "csh", "tcsh", "xonsh", + ]; + let base = base_name(shell_program); + OR_LIST.iter().any(|s| base.eq_ignore_ascii_case(s)) +} + /// Quote `path` as a single argument for the shell the pane is running. pub fn quote_for_shell(path: &str, shell_program: Option<&str>) -> String { quote_as(path, quoting_for(shell_program)) @@ -164,6 +182,30 @@ pub fn unquote_word(word: &str, quoting: Quoting) -> String { mod tests { use super::*; + #[test] + fn only_shells_with_an_or_list_are_handed_one() { + for yes in [ + "bash", + "/bin/zsh", + "/opt/homebrew/bin/fish", + r"C:\Program Files\PowerShell\7\pwsh.exe", + r"C:\Program Files\Git\bin\bash.exe", + "CMD.EXE", + ] { + assert!(runs_or_list(yes), "{yes}"); + } + for no in [ + "powershell.exe", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "nu", + "/usr/local/bin/elvish", + "wsl.exe", + "", + ] { + assert!(!runs_or_list(no), "{no}"); + } + } + #[test] fn a_plain_path_is_left_alone() { assert_eq!( diff --git a/src/ui/app.rs b/src/ui/app.rs index b4210b6d..b0c4b7b2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3891,19 +3891,21 @@ impl Tty7App { return; } let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx); - let resume = (!parts.restored) + let restored = parts.restored; + let view = build_terminal_view(parts, font_size, window, cx); + let resume = (!restored) .then(|| { let spawn = &pending.read(cx).spawn; agent_resume_command( &spawn.agent, spawn.agent_session_id.as_deref(), spawn.agent_launch_argv.as_deref(), + view.read(cx), cx, ) .or_else(|| spawn.run_on_land.clone()) }) .flatten(); - let view = build_terminal_view(parts, font_size, window, cx); if let Some(cmd) = resume { view.read(cx).run_command_line(&cmd); } @@ -9811,6 +9813,7 @@ fn agent_resume_command( agent: &Option, session_id: Option<&str>, launch_argv: Option<&[String]>, + view: &TerminalView, cx: &App, ) -> Option { if !cx.global::().restore_agent_sessions { @@ -9824,7 +9827,28 @@ fn agent_resume_command( ); return None; }; - agent.resume_command(session_id, launch_argv) + agent.restore_command( + session_id, + launch_argv, + pane_shell_program(view, cx).as_deref(), + ) +} + +/// The shell `view` is running, for deciding what a line typed into it may +/// use. A pane spawned without an explicit shell got the configured one or, +/// failing that, the login shell — but only a local pane got this machine's; +/// a workspace pane's default lives on its host, so it stays unknown. +fn pane_shell_program(view: &TerminalView, cx: &App) -> Option { + if let Some(spec) = view.shell_spec() { + return Some(spec.program); + } + if view.workspace().is_some() || view.ssh_spec().is_some() || view.remote_context().is_some() { + return None; + } + Some(match &cx.global::().shell { + Some(shell) if !shell.program.trim().is_empty() => shell.program.clone(), + _ => crate::core::shells::login_shell(), + }) } fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { @@ -10197,6 +10221,7 @@ fn session_to_pane( agent, agent_session_id.as_deref(), agent_launch_argv.as_deref(), + terminal.read(cx), cx, ) { terminal.read(cx).run_command_line(&cmd);