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
97 changes: 90 additions & 7 deletions src/process/launch.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::IsTerminal;
use std::process::Command;

use anyhow::{bail, Context, Result};
Expand Down Expand Up @@ -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<String> = 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")?;
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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<String>) -> Option<String> {
value.filter(|hint| !hint.trim().is_empty() && !hint.chars().any(char::is_control))
}

fn consume_resume_hint(cmd: &mut Command, value: Option<String>) -> Option<String> {
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;
}

Expand Down Expand Up @@ -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()
}));
}
}
120 changes: 106 additions & 14 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,
resume_hint: Option<&str>,
) -> Result<(Option<String>, bool)> {
// 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,8 @@ 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, 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 {
Expand All @@ -96,7 +100,7 @@ pub fn spawn_with_pty(mut cmd: Command, cwd: PathBuf) -> Result<Option<String>>
}
}

Ok(resume_session_id)
Ok((resume_session_id, managed_resume_hint_used))
}
}
}
Expand All @@ -107,18 +111,29 @@ fn run_proxy_loop(
master_fd: &OwnedFd,
stdin_handle: &std::io::Stdin,
detector: &mut LinkDetector,
) -> (Result<()>, Option<String>) {
resume_hint: Option<&str>,
) -> (Result<()>, Option<String>, bool) {
let mut resume_session_id: Option<String> = 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.
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<String>,
managed_resume_hint_used: &mut bool,
) -> Result<()> {
let stdin_borrowed: BorrowedFd = stdin_handle.as_fd();
let master_borrowed: BorrowedFd = master_fd.as_fd();
Expand All @@ -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()?;
Expand Down Expand Up @@ -197,9 +214,13 @@ fn run_proxy_loop_inner(
line_buf = line_buf[pos + 1..].to_string();

// 检测 `claude --resume <session-id>`
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}")?;
}

Expand All @@ -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()?;
}
Expand All @@ -227,18 +253,45 @@ fn run_proxy_loop_inner(
}

/// 从输出行中检测 `claude --resume <session-id>` 模式,提取 session ID。
fn detect_resume_session(line: &str, session_id: &mut Option<String>) {
fn detect_resume_session(line: &str, session_id: &mut Option<String>) -> bool {
// 匹配 ANSI 转义序列剥离后的纯文本,支持带/不带终端控制字符
let stripped = strip_ansi_escapes(line);
let trimmed = stripped.trim();
if let Some(rest) = trimmed.strip_prefix("claude --resume ") {
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<String>,
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());
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions website/src/content/docs/en/features/terminal-hyperlinks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Aside>

## 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:
Expand Down
10 changes: 10 additions & 0 deletions website/src/content/docs/zh-cn/features/terminal-hyperlinks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ PTY 代理对用户完全透明。仅在交互式会话中激活(使用 `--pri
如果遇到终端渲染问题,可以在配置中设置 `hyperlinks = false` 或省略 `--hyperlinks` 参数来禁用超链接。
</Aside>

## 托管的恢复提示

启动 Claudex 的工具可以通过 `CLAUDEX_RESUME_HINT` 设置一条完整的单行恢复命令。在 Unix 交互式会话中,Claudex 会用该值替换 Claude Code 原生的 `claude --resume` 命令,并省略额外的 Claudex 恢复提示。

```bash
CLAUDEX_RESUME_HINT="my-tool resume session-123" claudex run grok
```

即使终端超链接已禁用,托管恢复提示也会启用现有的 PTY 代理。空值或包含控制字符的值会被忽略。未设置该变量时,恢复输出保持不变。

## 强制启用/禁用

对于不在自动检测列表中的终端,可以强制启用超链接:
Expand Down