draft of tui sock

This commit is contained in:
Ryan Ragona
2025-04-26 09:13:57 -07:00
parent 63ec18989a
commit f2b7b14284
5 changed files with 298 additions and 14 deletions

44
codex-rs/Cargo.lock generated
View File

@@ -633,11 +633,14 @@ name = "codex-session"
version = "0.1.0"
dependencies = [
"anyhow",
"bytes",
"chrono",
"clap 4.5.37",
"codex-core",
"codex-exec",
"codex-repl",
"codex-tui",
"crossterm 0.27.0",
"dirs 5.0.1",
"libc",
"names",
@@ -663,7 +666,7 @@ dependencies = [
"codex-ansi-escape",
"codex-core",
"color-eyre",
"crossterm",
"crossterm 0.28.1",
"ratatui",
"shlex",
"tokio",
@@ -789,6 +792,22 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
dependencies = [
"bitflags 2.9.0",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -797,7 +816,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.0",
"crossterm_winapi",
"mio",
"mio 1.0.3",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
@@ -2139,6 +2158,18 @@ dependencies = [
"adler",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.3"
@@ -2723,7 +2754,7 @@ dependencies = [
"bitflags 2.9.0",
"cassowary",
"compact_str",
"crossterm",
"crossterm 0.28.1",
"indoc",
"instability",
"itertools 0.13.0",
@@ -3244,7 +3275,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio",
"mio 0.8.11",
"mio 1.0.3",
"signal-hook",
]
@@ -3720,7 +3752,7 @@ dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"mio 1.0.3",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@@ -3969,7 +4001,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
dependencies = [
"crossterm",
"crossterm 0.28.1",
"ratatui",
"unicode-width 0.2.0",
]

View File

@@ -25,6 +25,14 @@ tokio = { version = "1", features = [
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
# --- additions for PTY/TUI support ---
bytes = "1.5"
# Raw terminal handling when attaching to TUI sessions
crossterm = "0.27"
# PTY helpers (unix only)
# new dependencies for session management
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
@@ -37,16 +45,15 @@ serde_yaml = "0.9"
names = "0.14"
# unix-only process helpers
nix = { version = "0.27", optional = true, default-features = false, features = ["process"] }
libc = { version = "0.2", optional = true }
nix = { version = "0.27", default-features = false, features = ["process", "signal", "term"] }
# Re-use the codex-exec library for its CLI definition
codex_exec = { package = "codex-exec", path = "../exec" }
codex_repl = { package = "codex-repl", path = "../repl" }
codex_tui = { package = "codex-tui", path = "../tui" }
[dev-dependencies]
tempfile = "3"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.27", default-features = false, features = ["process"] }
libc = "0.2"

View File

@@ -84,6 +84,9 @@ enum AgentKind {
/// Interactive Read-Eval-Print-Loop agent.
Repl(ReplCreateCmd),
/// Full-screen Terminal User Interface agent.
Tui(TuiCreateCmd),
}
#[derive(Args)]
@@ -108,6 +111,12 @@ pub struct ReplCreateCmd {
repl_cli: codex_repl::Cli,
}
#[derive(Args)]
pub struct TuiCreateCmd {
#[clap(flatten)]
tui_cli: codex_tui::Cli,
}
impl CreateCmd {
pub async fn run(self) -> Result<()> {
let id = match &self.id {
@@ -132,6 +141,12 @@ impl CreateCmd {
let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p));
(child.id().unwrap_or_default(), preview, store::SessionKind::Repl)
}
AgentKind::Tui(cmd) => {
let args = build_tui_args(&cmd.tui_cli);
let child = spawn::spawn_tui(&paths, &args)?;
let preview = cmd.tui_cli.prompt.as_ref().map(|p| truncate_preview(p));
(child.id().unwrap_or_default(), preview, store::SessionKind::Tui)
}
};
// Persist metadata **after** the process has been spawned so we can record its PID.
@@ -267,6 +282,66 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec<String> {
args
}
// Build argument vector for spawning `codex-tui`.
// For the first implementation we forward only a minimal subset of options that
// are already handled in the REPL helper above. Future work can extend this
// with the full flag surface.
fn build_tui_args(cli: &codex_tui::Cli) -> Vec<String> {
let mut args = Vec::new();
// Positional prompt argument (optional) must be last.
if let Some(model) = &cli.model {
args.push("--model".into());
args.push(model.clone());
}
for img in &cli.images {
args.push("--image".into());
args.push(img.to_string_lossy().into_owned());
}
if cli.skip_git_repo_check {
args.push("--skip-git-repo-check".into());
}
if cli.disable_response_storage {
args.push("--disable-response-storage".into());
}
// Approval + sandbox policies
args.push("--ask-for-approval".into());
args.push(match cli.approval_policy {
codex_core::ApprovalModeCliArg::OnFailure => "on-failure".into(),
codex_core::ApprovalModeCliArg::UnlessAllowListed => "unless-allow-listed".into(),
codex_core::ApprovalModeCliArg::Never => "never".into(),
});
args.push("--sandbox".into());
args.push(match cli.sandbox_policy {
codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(),
codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(),
codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted =>
"network-and-file-write-restricted".into(),
codex_core::SandboxModeCliArg::DangerousNoRestrictions =>
"dangerous-no-restrictions".into(),
});
// Convenience flags
if cli.full_auto {
args.push("--full-auto".into());
}
if cli.suggest {
args.push("--suggest".into());
}
if let Some(prompt) = &cli.prompt {
args.push(prompt.clone());
}
args
}
// -----------------------------------------------------------------------------
// attach
@@ -282,14 +357,30 @@ pub struct AttachCmd {
impl AttachCmd {
pub async fn run(self) -> Result<()> {
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::time::sleep;
use tokio::time::Duration;
let id = store::resolve_selector(&self.id)?;
let paths = store::paths_for(&id)?;
// Load meta in order to decide which attach strategy to use.
let meta_bytes = std::fs::read(&paths.meta)?;
let meta: store::SessionMeta = serde_json::from_slice(&meta_bytes)?;
match meta.kind {
store::SessionKind::Exec | store::SessionKind::Repl => {
self.attach_line_oriented(&id, &paths).await
}
store::SessionKind::Tui => {
self.attach_tui(&paths).await
}
}
}
// ------------------------------------------------------------------
// Original FIFO based attach (exec / repl)
async fn attach_line_oriented(&self, id: &str, paths: &store::Paths) -> Result<()> {
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::time::{sleep, Duration};
// Ensure stdin pipe exists.
if !paths.stdin.exists() {
anyhow::bail!("session '{id}' is not interactive (stdin pipe missing)");
@@ -336,6 +427,60 @@ impl AttachCmd {
Ok(())
}
// ------------------------------------------------------------------
// TUI attach: raw byte forwarding over unix socket
async fn attach_tui(&self, paths: &store::Paths) -> Result<()> {
#[cfg(unix)]
{
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tokio::io::{self};
use tokio::net::UnixStream;
let sock_path = paths.dir.join("sock");
if !sock_path.exists() {
anyhow::bail!(
"tui session socket not found ({}). Is the session fully initialised?",
sock_path.display()
);
}
// Put local terminal in raw mode undone automatically at drop.
enable_raw_mode()?;
// Connect to the session socket.
let stream = UnixStream::connect(&sock_path).await?;
let (mut reader, mut writer) = stream.into_split();
let mut stdin = tokio::io::stdin();
let mut stdout = tokio::io::stdout();
// Two independent tasks: socket → stdout and stdin → socket.
let to_stdout = tokio::spawn(async move {
io::copy(&mut reader, &mut stdout).await
});
let to_socket = tokio::spawn(async move {
io::copy(&mut stdin, &mut writer).await
});
let res = tokio::select! {
r = to_stdout => r?,
r = to_socket => r?,
};
disable_raw_mode()?;
// Propagate I/O errors if any.
res?;
Ok(())
}
#[cfg(not(unix))]
{
anyhow::bail!("tui sessions are only supported on Unix at the moment");
}
}
}
// -----------------------------------------------------------------------------

View File

@@ -142,3 +142,99 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result<Child> {
anyhow::bail!("codex-repl background sessions are not yet supported on Windows");
}
}
/// Spawn a `codex-tui` agent **inside a pseudo-terminal (pty)** so that a later
/// `codex-session attach` command can hook up an interactive terminal. The
/// current implementation is intentionally minimal: it only takes care of
/// running the agent detached in the background and redirecting the master
/// side of the pty to `stdout.log`. A future patch will extend this with a
/// proper multi-client socket fan-out as outlined in the design document the
/// extra indirection is *not* required for compilation tests.
pub fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result<Child> {
#[cfg(unix)]
{
use std::io;
use std::os::unix::io::{FromRawFd, IntoRawFd, RawFd};
// Allocate a new pty.
let pty = nix::pty::openpty(None, None).context("failed to open pty")?;
// Safe because we immediately hand the raw fds to Stdio which takes
// ownership.
// Extract *raw* fds from the OwnedFd handles returned by nix.
let slave_fd: RawFd = pty.slave.into_raw_fd();
let master_fd: RawFd = pty.master.into_raw_fd();
// Helper to wrap a raw fd into a Stdio object (takes ownership).
let make_stdio_from_fd = |fd: RawFd| unsafe { std::process::Stdio::from_raw_fd(fd) };
// SAFETY: libc::dup returns a new fd or -1 on error (checked).
let dup_fd = |fd: RawFd| -> Result<RawFd> {
let new_fd = unsafe { libc::dup(fd) };
if new_fd == -1 {
Err(anyhow::anyhow!(std::io::Error::last_os_error()))
} else {
Ok(new_fd)
}
};
let stdin = make_stdio_from_fd(dup_fd(slave_fd)?);
let stdout = make_stdio_from_fd(dup_fd(slave_fd)?);
let stderr = make_stdio_from_fd(slave_fd);
// Spawn the codex-tui process *detached* from our controlling tty so
// the background session survives once the `create` CLI exits.
let mut cmd = Command::new("codex-tui");
cmd.args(tui_args)
.stdin(stdin)
.stdout(stdout)
.stderr(stderr);
unsafe {
cmd.pre_exec(|| {
// Create new session (like setsid()) so the child is not tied
// to the CLI process.
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
libc::signal(libc::SIGHUP, libc::SIG_IGN);
Ok(())
});
}
// Spawn child first so that we know its PID for metadata.
let child = cmd.spawn().context("failed to spawn codex-tui")?;
// ------------ background copy: master → stdout.log ---------------
// Turn the master fd into a std::fs::File which **owns** the fd.
let master_file = unsafe { std::fs::File::from_raw_fd(master_fd) };
let mut log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&paths.stdout)?;
// Spawn blocking thread instead of async; simpler and good enough for
// the build-time smoke tests.
std::thread::spawn(move || {
use std::io::{Read, Write};
let mut r = master_file;
let mut buf = [0u8; 4096];
loop {
match r.read(&mut buf) {
Ok(0) => break, // eof
Ok(n) => {
let _ = log_file.write_all(&buf[..n]);
}
Err(_) => break,
}
}
});
Ok(child)
}
#[cfg(windows)]
{
anyhow::bail!("codex-tui sessions are not yet supported on Windows");
}
}

View File

@@ -55,8 +55,12 @@ pub struct SessionMeta {
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
/// Non-interactive batch session `codex-exec`.
Exec,
/// Line-oriented interactive session `codex-repl`.
Repl,
/// Full terminal-UI session (crossterm / ratatui) `codex-tui`.
Tui,
}
impl Default for SessionKind {