Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 80 additions & 12 deletions src/process/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,15 @@ pub fn launch_claude(
"launching claude"
);

let resume_hint_override = resume_hint_override();

// PTY mode (Unix only): 非交互模式跳过 PTY
#[cfg(unix)]
let use_pty = !is_noninteractive && should_use_pty(&config.hyperlinks, hyperlinks_override);
let use_pty = should_launch_with_pty(
is_noninteractive,
should_use_pty(&config.hyperlinks, hyperlinks_override),
resume_hint_override.is_some(),
);
#[cfg(not(unix))]
let use_pty = false;

Expand All @@ -101,7 +107,8 @@ pub fn launch_claude(
{
tracing::info!("hyperlinks enabled, 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 =
terminal::pty::spawn_with_pty(cmd, cwd, resume_hint_override.is_some())?;
}
} else {
let mut child = cmd.spawn().context("failed to execute claude binary")?;
Expand Down Expand Up @@ -133,20 +140,44 @@ pub fn launch_claude(

// 追加 claudex resume 命令提示
if let Some(session_id) = resume_session_id {
print_claudex_resume_hint(&profile.name, &session_id, extra_args);
print_claudex_resume_hint(
&profile.name,
&session_id,
extra_args,
resume_hint_override.as_deref(),
);
}

Ok(())
}

/// 在 Claude Code 退出后追加 claudex resume 命令提示
fn print_claudex_resume_hint(profile_name: &str, session_id: &str, extra_args: &[String]) {
let hint = build_resume_hint(profile_name, session_id, extra_args);
eprintln!("\nResume this session with claudex:\n {hint}");
fn print_claudex_resume_hint(
profile_name: &str,
session_id: &str,
extra_args: &[String],
override_hint: Option<&str>,
) {
let hint = build_resume_hint(profile_name, session_id, extra_args, override_hint);
let owner = if override_hint.is_some() {
""
} else {
" claudex"
};
eprintln!("\nResume this session with{owner}:\n {hint}");
}

/// 构造 claudex resume 命令字符串(纯函数,便于测试)
fn build_resume_hint(profile_name: &str, session_id: &str, extra_args: &[String]) -> String {
fn build_resume_hint(
profile_name: &str,
session_id: &str,
extra_args: &[String],
override_hint: Option<&str>,
) -> String {
if let Some(hint) = override_hint {
return hint.to_string();
}

// 过滤掉原始 extra_args 中的 --resume 及其值参数
let mut args_clean: Vec<&str> = Vec::new();
let mut skip_next = false;
Expand All @@ -171,8 +202,27 @@ fn build_resume_hint(profile_name: &str, session_id: &str, extra_args: &[String]
format!("claudex run {profile_name} --resume {session_id}{args_str}")
}

fn resume_hint_override() -> Option<String> {
let value = std::env::var("CLAUDEX_RESUME_HINT").ok()?;
let hint = value.trim();
if hint.is_empty() || hint.len() > 1024 || hint.chars().any(char::is_control) {
return None;
}
Some(hint.to_string())
}

/// Decide whether to use PTY mode based on config + CLI flag.
#[cfg(unix)]
fn should_launch_with_pty(
is_noninteractive: bool,
hyperlinks_enabled: bool,
has_owned_resume_hint: bool,
) -> bool {
!is_noninteractive && (hyperlinks_enabled || has_owned_resume_hint)
}

/// Decide whether hyperlinks require PTY mode based on config + CLI flag.
#[cfg(unix)]
fn should_use_pty(config_hyperlinks: &HyperlinksConfig, cli_override: bool) -> bool {
if cli_override {
return true;
Expand All @@ -191,7 +241,7 @@ mod tests {

#[test]
fn test_build_resume_hint_no_extra_args() {
let hint = build_resume_hint("codex-sub", "abc-123", &[]);
let hint = build_resume_hint("codex-sub", "abc-123", &[], None);
assert_eq!(hint, "claudex run codex-sub --resume abc-123");
}

Expand All @@ -201,7 +251,7 @@ mod tests {
"--dangerously-skip-permissions".to_string(),
"--verbose".to_string(),
];
let hint = build_resume_hint("codex-sub", "abc-123", &args);
let hint = build_resume_hint("codex-sub", "abc-123", &args, None);
assert_eq!(
hint,
"claudex run codex-sub --resume abc-123 --dangerously-skip-permissions --verbose"
Expand All @@ -215,7 +265,7 @@ mod tests {
"old-session-id".to_string(),
"--dangerously-skip-permissions".to_string(),
];
let hint = build_resume_hint("codex-sub", "new-session-id", &args);
let hint = build_resume_hint("codex-sub", "new-session-id", &args, None);
assert_eq!(
hint,
"claudex run codex-sub --resume new-session-id --dangerously-skip-permissions"
Expand All @@ -229,14 +279,32 @@ mod tests {
"--resume".to_string(),
"old-id".to_string(),
];
let hint = build_resume_hint("my-profile", "new-id", &args);
let hint = build_resume_hint("my-profile", "new-id", &args, None);
assert_eq!(hint, "claudex run my-profile --resume new-id --verbose");
}

#[test]
fn test_build_resume_hint_resume_only() {
let args = vec!["--resume".to_string(), "old-id".to_string()];
let hint = build_resume_hint("p", "new-id", &args);
let hint = build_resume_hint("p", "new-id", &args, None);
assert_eq!(hint, "claudex run p --resume new-id");
}

#[test]
fn test_build_resume_hint_prefers_owned_override() {
let hint = build_resume_hint(
"codex-sub",
"abc-123",
&[],
Some("orichum resume oc-s-0000000000000001"),
);
assert_eq!(hint, "orichum resume oc-s-0000000000000001");
}

#[cfg(unix)]
#[test]
fn test_owned_resume_hint_forces_pty_without_hyperlinks() {
assert!(should_launch_with_pty(false, false, true));
assert!(!should_launch_with_pty(true, false, true));
}
}
67 changes: 57 additions & 10 deletions src/terminal/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<String>> {
pub fn spawn_with_pty(
mut cmd: Command,
cwd: PathBuf,
suppress_resume_footer: bool,
) -> Result<Option<String>> {
// Open a PTY pair
let pty = openpty(None, None).context("failed to open PTY")?;
let master_fd = pty.master;
Expand Down Expand Up @@ -73,8 +77,12 @@ pub fn spawn_with_pty(mut cmd: Command, cwd: PathBuf) -> Result<Option<String>>
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) = run_proxy_loop(
&master_fd,
&stdin,
&mut LinkDetector::new(cwd),
suppress_resume_footer,
);

// Restore terminal settings
if let Some(ref orig) = orig_termios {
Expand Down Expand Up @@ -107,9 +115,16 @@ fn run_proxy_loop(
master_fd: &OwnedFd,
stdin_handle: &std::io::Stdin,
detector: &mut LinkDetector,
suppress_resume_footer: bool,
) -> (Result<()>, Option<String>) {
let mut resume_session_id: Option<String> = None;
let result = run_proxy_loop_inner(master_fd, stdin_handle, detector, &mut resume_session_id);
let result = run_proxy_loop_inner(
master_fd,
stdin_handle,
detector,
&mut resume_session_id,
suppress_resume_footer,
);
(result, resume_session_id)
}

Expand All @@ -119,6 +134,7 @@ fn run_proxy_loop_inner(
stdin_handle: &std::io::Stdin,
detector: &mut LinkDetector,
resume_session_id: &mut Option<String>,
suppress_resume_footer: bool,
) -> Result<()> {
let stdin_borrowed: BorrowedFd = stdin_handle.as_fd();
let master_borrowed: BorrowedFd = master_fd.as_fd();
Expand All @@ -139,8 +155,10 @@ fn run_proxy_loop_inner(
Ok(0) => {
// Timeout: flush any incomplete line buffer to avoid display lag
if !line_buf.is_empty() {
let enhanced = detector.enhance_line(&line_buf);
write!(stdout, "{enhanced}")?;
if !should_suppress_resume_footer_line(&line_buf, suppress_resume_footer) {
let enhanced = detector.enhance_line(&line_buf);
write!(stdout, "{enhanced}")?;
}
stdout.flush()?;
line_buf.clear();
}
Expand Down Expand Up @@ -199,8 +217,10 @@ fn run_proxy_loop_inner(
// 检测 `claude --resume <session-id>`
detect_resume_session(&line, resume_session_id);

let enhanced = detector.enhance_line(&line);
writeln!(stdout, "{enhanced}")?;
if !should_suppress_resume_footer_line(&line, suppress_resume_footer) {
let enhanced = detector.enhance_line(&line);
writeln!(stdout, "{enhanced}")?;
}
}

stdout.flush()?;
Expand All @@ -214,8 +234,10 @@ fn run_proxy_loop_inner(
// 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);
write!(stdout, "{enhanced}")?;
if !should_suppress_resume_footer_line(&line_buf, suppress_resume_footer) {
let enhanced = detector.enhance_line(&line_buf);
write!(stdout, "{enhanced}")?;
}
stdout.flush()?;
}
break;
Expand All @@ -239,6 +261,15 @@ fn detect_resume_session(line: &str, session_id: &mut Option<String>) {
}
}

fn should_suppress_resume_footer_line(line: &str, enabled: bool) -> bool {
if !enabled {
return false;
}
let stripped = strip_ansi_escapes(line);
let trimmed = stripped.trim();
trimmed == "Resume this session with:" || trimmed.starts_with("claude --resume ")
}

/// 剥离 ANSI 转义序列(CSI、OSC 等)
fn strip_ansi_escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
Expand Down Expand Up @@ -504,4 +535,20 @@ mod tests {
let data = &[b'h', b'i', 0xE4, 0xB8];
assert_eq!(find_utf8_safe_end(data), 2);
}

#[test]
fn test_owned_resume_hint_suppresses_claude_footer_lines() {
assert!(should_suppress_resume_footer_line(
"Resume this session with:",
true,
));
assert!(should_suppress_resume_footer_line(
" claude --resume abc-123",
true,
));
assert!(!should_suppress_resume_footer_line(
"Resume this session with:",
false,
));
}
}