diff --git a/src/process/launch.rs b/src/process/launch.rs index 4008987..14f4314 100644 --- a/src/process/launch.rs +++ b/src/process/launch.rs @@ -1,3 +1,4 @@ +use std::io::IsTerminal; use std::process::Command; use anyhow::{bail, Context, Result}; @@ -88,20 +89,30 @@ pub fn launch_claude( "launching claude" ); + let resume_hint = consume_resume_hint(&mut cmd, std::env::var("CLAUDEX_RESUME_HINT").ok()); + // PTY mode (Unix only): 非交互模式跳过 PTY #[cfg(unix)] - let use_pty = !is_noninteractive && should_use_pty(&config.hyperlinks, hyperlinks_override); + let use_pty = !is_noninteractive + && should_use_pty( + &config.hyperlinks, + hyperlinks_override, + resume_hint.is_some(), + std::io::stdin().is_terminal() && std::io::stdout().is_terminal(), + ); #[cfg(not(unix))] let use_pty = false; let mut resume_session_id: Option = None; + let mut managed_resume_hint_used = false; if use_pty { #[cfg(unix)] { - tracing::info!("hyperlinks enabled, using PTY proxy mode"); + tracing::info!("using PTY proxy mode"); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); - resume_session_id = terminal::pty::spawn_with_pty(cmd, cwd)?; + (resume_session_id, managed_resume_hint_used) = + terminal::pty::spawn_with_pty(cmd, cwd, resume_hint.as_deref())?; } } else { let mut child = cmd.spawn().context("failed to execute claude binary")?; @@ -132,8 +143,10 @@ pub fn launch_claude( } // 追加 claudex resume 命令提示 - if let Some(session_id) = resume_session_id { - print_claudex_resume_hint(&profile.name, &session_id, extra_args); + if !managed_resume_hint_used { + if let Some(session_id) = resume_session_id { + print_claudex_resume_hint(&profile.name, &session_id, extra_args); + } } Ok(()) @@ -171,10 +184,24 @@ fn build_resume_hint(profile_name: &str, session_id: &str, extra_args: &[String] format!("claudex run {profile_name} --resume {session_id}{args_str}") } +fn valid_resume_hint(value: Option) -> Option { + value.filter(|hint| !hint.trim().is_empty() && !hint.chars().any(char::is_control)) +} + +fn consume_resume_hint(cmd: &mut Command, value: Option) -> Option { + cmd.env_remove("CLAUDEX_RESUME_HINT"); + valid_resume_hint(value) +} + /// Decide whether to use PTY mode based on config + CLI flag. #[cfg(unix)] -fn should_use_pty(config_hyperlinks: &HyperlinksConfig, cli_override: bool) -> bool { - if cli_override { +fn should_use_pty( + config_hyperlinks: &HyperlinksConfig, + cli_override: bool, + managed_resume_hint: bool, + interactive_terminal: bool, +) -> bool { + if cli_override || (managed_resume_hint && interactive_terminal) { return true; } @@ -239,4 +266,60 @@ mod tests { let hint = build_resume_hint("p", "new-id", &args); assert_eq!(hint, "claudex run p --resume new-id"); } + + #[test] + fn test_valid_resume_hint_accepts_one_line_command() { + assert_eq!( + valid_resume_hint(Some("orichum resume oc-s-0123456789abcdef".to_string())), + Some("orichum resume oc-s-0123456789abcdef".to_string()) + ); + } + + #[test] + fn test_valid_resume_hint_rejects_control_characters() { + assert_eq!( + valid_resume_hint(Some("orichum resume safe\nunsafe".to_string())), + None + ); + assert_eq!( + valid_resume_hint(Some("orichum resume safe\x1b[2J".to_string())), + None + ); + } + + #[cfg(unix)] + #[test] + fn test_managed_resume_hint_requires_pty() { + assert!(should_use_pty( + &HyperlinksConfig::Disabled, + false, + true, + true + )); + assert!(!should_use_pty( + &HyperlinksConfig::Disabled, + false, + true, + false + )); + } + + #[test] + fn test_consume_resume_hint_removes_child_environment() { + let mut command = Command::new("claude"); + command.env("CLAUDEX_RESUME_HINT", "inherited"); + + let hint = consume_resume_hint( + &mut command, + Some("orichum resume oc-s-0123456789abcdef".to_string()), + ); + + assert_eq!( + hint.as_deref(), + Some("orichum resume oc-s-0123456789abcdef") + ); + assert!(command.get_envs().any(|(name, value)| { + name == std::ffi::OsStr::new("CLAUDEX_RESUME_HINT") && value.is_none() + })); + } } diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index b3bdb2f..2b18fbb 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -14,7 +14,11 @@ use super::osc8::LinkDetector; /// Launch a child process in a PTY and proxy its output through the link detector. /// Returns the detected resume session ID if Claude Code output a `claude --resume` line. -pub fn spawn_with_pty(mut cmd: Command, cwd: PathBuf) -> Result> { +pub fn spawn_with_pty( + mut cmd: Command, + cwd: PathBuf, + resume_hint: Option<&str>, +) -> Result<(Option, bool)> { // Open a PTY pair let pty = openpty(None, None).context("failed to open PTY")?; let master_fd = pty.master; @@ -73,8 +77,8 @@ pub fn spawn_with_pty(mut cmd: Command, cwd: PathBuf) -> Result> setup_sigwinch_handler(stdin.as_raw_fd(), master_fd.as_raw_fd()); // Run the proxy loop - let (exit_code, resume_session_id) = - run_proxy_loop(&master_fd, &stdin, &mut LinkDetector::new(cwd)); + let (exit_code, resume_session_id, managed_resume_hint_used) = + run_proxy_loop(&master_fd, &stdin, &mut LinkDetector::new(cwd), resume_hint); // Restore terminal settings if let Some(ref orig) = orig_termios { @@ -96,7 +100,7 @@ pub fn spawn_with_pty(mut cmd: Command, cwd: PathBuf) -> Result> } } - Ok(resume_session_id) + Ok((resume_session_id, managed_resume_hint_used)) } } } @@ -107,10 +111,19 @@ fn run_proxy_loop( master_fd: &OwnedFd, stdin_handle: &std::io::Stdin, detector: &mut LinkDetector, -) -> (Result<()>, Option) { + resume_hint: Option<&str>, +) -> (Result<()>, Option, bool) { let mut resume_session_id: Option = None; - let result = run_proxy_loop_inner(master_fd, stdin_handle, detector, &mut resume_session_id); - (result, resume_session_id) + let mut managed_resume_hint_used = false; + let result = run_proxy_loop_inner( + master_fd, + stdin_handle, + detector, + resume_hint, + &mut resume_session_id, + &mut managed_resume_hint_used, + ); + (result, resume_session_id, managed_resume_hint_used) } /// Inner proxy loop with `?` operator support. @@ -118,7 +131,9 @@ fn run_proxy_loop_inner( master_fd: &OwnedFd, stdin_handle: &std::io::Stdin, detector: &mut LinkDetector, + resume_hint: Option<&str>, resume_session_id: &mut Option, + managed_resume_hint_used: &mut bool, ) -> Result<()> { let stdin_borrowed: BorrowedFd = stdin_handle.as_fd(); let master_borrowed: BorrowedFd = master_fd.as_fd(); @@ -138,7 +153,9 @@ fn run_proxy_loop_inner( match nix::poll::poll(&mut fds, PollTimeout::from(50u16)) { Ok(0) => { // Timeout: flush any incomplete line buffer to avoid display lag - if !line_buf.is_empty() { + if !line_buf.is_empty() + && !(resume_hint.is_some() && is_partial_resume_line(&line_buf)) + { let enhanced = detector.enhance_line(&line_buf); write!(stdout, "{enhanced}")?; stdout.flush()?; @@ -197,9 +214,13 @@ fn run_proxy_loop_inner( line_buf = line_buf[pos + 1..].to_string(); // 检测 `claude --resume ` - detect_resume_session(&line, resume_session_id); - - let enhanced = detector.enhance_line(&line); + let output = resume_output_line( + &line, + resume_hint, + resume_session_id, + managed_resume_hint_used, + ); + let enhanced = detector.enhance_line(output); writeln!(stdout, "{enhanced}")?; } @@ -213,8 +234,13 @@ fn run_proxy_loop_inner( if revents.contains(PollFlags::POLLHUP) { // Child exited: flush remaining buffer if !line_buf.is_empty() { - detect_resume_session(&line_buf, resume_session_id); - let enhanced = detector.enhance_line(&line_buf); + let output = resume_output_line( + &line_buf, + resume_hint, + resume_session_id, + managed_resume_hint_used, + ); + let enhanced = detector.enhance_line(output); write!(stdout, "{enhanced}")?; stdout.flush()?; } @@ -227,7 +253,7 @@ fn run_proxy_loop_inner( } /// 从输出行中检测 `claude --resume ` 模式,提取 session ID。 -fn detect_resume_session(line: &str, session_id: &mut Option) { +fn detect_resume_session(line: &str, session_id: &mut Option) -> bool { // 匹配 ANSI 转义序列剥离后的纯文本,支持带/不带终端控制字符 let stripped = strip_ansi_escapes(line); let trimmed = stripped.trim(); @@ -235,10 +261,37 @@ fn detect_resume_session(line: &str, session_id: &mut Option) { let id = rest.trim(); if !id.is_empty() { *session_id = Some(id.to_string()); + return true; + } + } + false +} + +fn resume_output_line<'a>( + line: &'a str, + resume_hint: Option<&'a str>, + session_id: &mut Option, + managed_resume_hint_used: &mut bool, +) -> &'a str { + if detect_resume_session(line, session_id) { + if let Some(hint) = resume_hint { + *managed_resume_hint_used = true; + hint + } else { + line } + } else { + line } } +fn is_partial_resume_line(line: &str) -> bool { + let stripped = strip_ansi_escapes(line); + let candidate = stripped.trim(); + !candidate.is_empty() + && ("claude --resume ".starts_with(candidate) || candidate.starts_with("claude --resume ")) +} + /// 剥离 ANSI 转义序列(CSI、OSC 等) fn strip_ansi_escapes(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -472,6 +525,45 @@ mod tests { assert_eq!(id.as_deref(), Some("abc-456")); } + #[test] + fn test_resume_output_line_uses_managed_hint() { + let mut id = None; + let mut managed_hint_used = false; + let line = resume_output_line( + "claude --resume abc-123", + Some("orichum resume oc-s-0123456789abcdef"), + &mut id, + &mut managed_hint_used, + ); + + assert_eq!(line, "orichum resume oc-s-0123456789abcdef"); + assert_eq!(id.as_deref(), Some("abc-123")); + assert!(managed_hint_used); + } + + #[test] + fn test_resume_output_line_preserves_default_output() { + let mut id = None; + let mut managed_hint_used = false; + let line = resume_output_line( + "claude --resume abc-123", + None, + &mut id, + &mut managed_hint_used, + ); + + assert_eq!(line, "claude --resume abc-123"); + assert_eq!(id.as_deref(), Some("abc-123")); + assert!(!managed_hint_used); + } + + #[test] + fn test_partial_resume_line_is_held_across_timeouts() { + assert!(is_partial_resume_line("claude --res")); + assert!(is_partial_resume_line("claude --resume abc-123")); + assert!(!is_partial_resume_line("ordinary output")); + } + // ── find_utf8_safe_end ────────────────────────────────── #[test] diff --git a/website/src/content/docs/en/features/terminal-hyperlinks.mdx b/website/src/content/docs/en/features/terminal-hyperlinks.mdx index c34517f..1464b06 100644 --- a/website/src/content/docs/en/features/terminal-hyperlinks.mdx +++ b/website/src/content/docs/en/features/terminal-hyperlinks.mdx @@ -89,6 +89,21 @@ The PTY proxy is transparent to the user. It is only activated for interactive s If you experience issues with terminal rendering, you can disable hyperlinks with `hyperlinks = false` in your config or by omitting the `--hyperlinks` flag. +## Managed Resume Hint + +Tools that launch Claudex can set `CLAUDEX_RESUME_HINT` to an exact one-line +resume command. For interactive Unix sessions, Claudex replaces Claude Code's +native `claude --resume` command with this value and omits its additional +Claudex resume hint. + +```bash +CLAUDEX_RESUME_HINT="my-tool resume session-123" claudex run grok +``` + +The managed hint activates the existing PTY proxy even when terminal +hyperlinks are disabled. Empty values and values containing control characters +are ignored. When the variable is unset, resume output is unchanged. + ## Force Enable/Disable For terminals not in the auto-detection list, you can force hyperlinks: diff --git a/website/src/content/docs/zh-cn/features/terminal-hyperlinks.mdx b/website/src/content/docs/zh-cn/features/terminal-hyperlinks.mdx index 32c90fa..0955c0e 100644 --- a/website/src/content/docs/zh-cn/features/terminal-hyperlinks.mdx +++ b/website/src/content/docs/zh-cn/features/terminal-hyperlinks.mdx @@ -89,6 +89,16 @@ PTY 代理对用户完全透明。仅在交互式会话中激活(使用 `--pri 如果遇到终端渲染问题,可以在配置中设置 `hyperlinks = false` 或省略 `--hyperlinks` 参数来禁用超链接。 +## 托管的恢复提示 + +启动 Claudex 的工具可以通过 `CLAUDEX_RESUME_HINT` 设置一条完整的单行恢复命令。在 Unix 交互式会话中,Claudex 会用该值替换 Claude Code 原生的 `claude --resume` 命令,并省略额外的 Claudex 恢复提示。 + +```bash +CLAUDEX_RESUME_HINT="my-tool resume session-123" claudex run grok +``` + +即使终端超链接已禁用,托管恢复提示也会启用现有的 PTY 代理。空值或包含控制字符的值会被忽略。未设置该变量时,恢复输出保持不变。 + ## 强制启用/禁用 对于不在自动检测列表中的终端,可以强制启用超链接: