Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ and this project adheres to
attribution (`Co-Authored-By:`, `Claude-Session:`, "Generated with Claude
Code") out of commits and pull requests, even if a session's own reminder
asks for one.
- `list`'s human table gets a leading `#` column, a 1-based position; `attach`
and `cancel` now take that position in place of a JOBID or NAME
(`sinteractive attach 1`), read against a bare number that no running
session's job id actually matches.

### Fixed

- `attach` and launch-and-attach could leak raw escape bytes into the
freshly attached pane on a laggy reconnect: the theme-detection query they
ran for stderr narration shares the terminal with the zellij client
they're about to exec into, so a reply arriving after its 500ms timeout
landed as literal control characters in that pane instead of at a shell
prompt. That narration no longer queries the terminal live.

## [1.4.0] - 2026-09-18

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ environment variables.
| | `srun --pty bash` | `sinteractive` |
|---|---|---|
| Survives SSH disconnects | No — session is lost | Yes — the zellij server keeps it alive |
| Reconnect to session | Not possible | `sinteractive attach JOBID\|NAME` |
| Reconnect to session | Not possible | `sinteractive attach JOBID\|NAME\|#` |
| Multiple panes | No | Yes — splits, zoom, scrollback (`Ctrl+b`) |
| Mouse and copy | Terminal's own | Mouse on by default; select-to-copy lands in your local clipboard |
| Status bar | None | Job id, node, walltime left, your queue, notices (`⚠ N notices`) |
Expand Down
12 changes: 12 additions & 0 deletions crates/sint-core/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ impl Palette {
Palette::from_theme(&Theme::detect(fd))
}

/// [`Palette::for_fd`], but through [`Theme::detect_no_query`] — for
/// narration that sits right next to exec'ing into (or was just handed
/// the terminal back from) an interactive zellij client, where the live
/// query's late-reply risk would land in that client's pane instead of
/// at a shell prompt.
pub fn for_fd_no_query(mode: ColorMode, fd: i32) -> Self {
if !colour_wanted(mode, fd) {
return Palette::none();
}
Palette::from_theme(&Theme::detect_no_query(fd))
}

/// Every code from `theme`, regardless of the fd.
pub fn from_theme(theme: &Theme) -> Self {
Palette {
Expand Down
33 changes: 33 additions & 0 deletions crates/sint-core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ pub fn sessions_only(rows: &[JobRow]) -> Vec<&JobRow> {
.collect()
}

/// A bare number given as a target, read as a 1-based position in `rows`
/// (the order `list` prints its `#` column in) — but only when no row
/// actually has that job id. Real Slurm job ids run into the hundreds of
/// thousands, well past any realistic session count, so a literal job id
/// always wins; this only ever engages for a small number nobody's job has.
pub fn resolve_index(n: u64, rows: &[JobRow]) -> Option<u64> {
if rows.iter().any(|r| r.job_id == n) {
return None;
}
let idx = usize::try_from(n).ok()?.checked_sub(1)?;
rows.get(idx).map(|r| r.job_id)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -379,6 +392,26 @@ mod tests {
assert_eq!(err, "multiple sinteractive sessions named 'dup': 10 11");
}

#[test]
fn resolve_index_reads_position_when_no_job_has_it() {
let rows = vec![
row(147845, "sinteractive:web", "RUNNING"),
row(147846, "sinteractive", "RUNNING"),
];
assert_eq!(resolve_index(1, &rows), Some(147845));
assert_eq!(resolve_index(2, &rows), Some(147846));
assert_eq!(resolve_index(3, &rows), None, "past the end");
assert_eq!(resolve_index(0, &rows), None, "1-based, no zeroth row");
}

#[test]
fn resolve_index_defers_to_a_real_job_id() {
// A running job actually numbered 1 is vanishingly unlikely, but if
// one exists it is the literal target, not "position 1".
let rows = vec![row(1, "sinteractive:web", "RUNNING")];
assert_eq!(resolve_index(1, &rows), None);
}

#[test]
fn sessions_only_filters_by_comment() {
let rows = vec![
Expand Down
40 changes: 37 additions & 3 deletions crates/sint-core/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,30 @@ impl Theme {
/// never leaves the terminal in raw mode. Prints nothing except the query
/// itself, which the terminal consumes.
pub fn detect(fd: i32) -> Theme {
Theme::for_mode(detect_mode(fd))
Theme::for_mode(detect_mode(fd, true))
}

/// [`Theme::detect`], but never sending the live query — env override,
/// then `COLORFGBG`, then dark.
///
/// For a caller about to exec into (or that just took the terminal back
/// from) an interactive zellij client: `query_background`'s up-to-500ms
/// window for a reply is exactly the window in which a late answer lands
/// as raw escape bytes in that client's freshly drawn pane, rather than
/// as harmless line noise at a shell prompt.
pub fn detect_no_query(fd: i32) -> Theme {
Theme::for_mode(detect_mode(fd, false))
}
}

fn detect_mode(fd: i32) -> Mode {
fn detect_mode(fd: i32, query: bool) -> Mode {
if let Some(mode) = std::env::var("SINTERACTIVE_THEME")
.ok()
.and_then(|v| Mode::parse(&v))
{
return mode;
}
if is_tty(fd) {
if query && is_tty(fd) {
if let Some(bg) = background() {
return Mode::from_background(bg);
}
Expand Down Expand Up @@ -541,6 +553,28 @@ mod tests {
assert_eq!(Theme::detect(-1), Theme::DARK);
}

#[test]
fn detect_no_query_follows_the_same_fallback_chain() {
// `is_tty` gates the live query in `detect_mode`, so a non-tty fd
// cannot show `detect_no_query` skipping a query `detect` would have
// sent (that needs a real pty, untestable here) — but it does prove
// the env-override/COLORFGBG/dark fallback chain underneath is the
// same for both.
let _g = lock();
let _r = EnvRestore::clean();
let file = tempfile::tempfile().expect("tempfile");
let fd = file.as_raw_fd();

std::env::set_var("SINTERACTIVE_THEME", "light");
assert_eq!(Theme::detect_no_query(fd), Theme::LIGHT);

std::env::set_var("SINTERACTIVE_THEME", "auto");
std::env::set_var("COLORFGBG", "0;15");
assert_eq!(Theme::detect_no_query(fd).mode, Mode::Light);
std::env::remove_var("COLORFGBG");
assert_eq!(Theme::detect_no_query(fd), Theme::DARK);
}

#[test]
fn theme_lookup() {
assert_eq!(Theme::for_mode(Mode::Light).mode, Mode::Light);
Expand Down
3 changes: 2 additions & 1 deletion crates/sint/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ pub struct TargetArgs {

#[derive(Args, Debug, Clone, Default)]
pub struct AttachArgs {
/// JOBID or NAME; with no target, your only session
/// JOBID, NAME, or list's #; with no target, your only session
pub target: Option<String>,
/// Attach over ssh -X (X11 forwarding) instead of srun --overlap
#[arg(long)]
Expand All @@ -341,6 +341,7 @@ pub struct EnsureArgs {

#[derive(Args, Debug, Clone, Default)]
pub struct CancelArgs {
/// JOBID, NAME, or list's #
pub target: String,
#[arg(long)]
pub json: bool,
Expand Down
16 changes: 10 additions & 6 deletions crates/sint/src/commands/attach.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! `sinteractive attach [TARGET]` — reattach (script lines 2208-2258).
//!
//! No target: your only running session; with none, say how to start one;
//! with several, print the ready-to-run choices and exit 1. The default
//! with several, print the ready-to-run choices and exit 1, each prefixed
//! with its position — TARGET can be that position, as printed in `list`'s
//! `#` column, instead of the job id or name (`Ctx::resolve`). The default
//! path goes through Slurm (`srun --overlap --jobid=ID --pty …`), which
//! needs no ssh access to the node; `--ssh` forces `ssh -X -t NODE …`, the
//! launch path's transport, for X11 forwarding.
Expand All @@ -17,7 +19,9 @@ use crate::cli::AttachArgs;

pub fn run(args: AttachArgs) -> Result<i32> {
let ctx = Ctx::new();
let p = ctx.palette(2);
// Narration right before exec'ing into the zellij client on this same
// terminal — no live query (see `Ctx::palette_no_query`).
let p = ctx.palette_no_query(2);
let (reset, bold, dim, err, id, warn) = (&p.reset, &p.bold, &p.dim, &p.err, &p.id, &p.warn);

// Attaching from inside a session would nest multiplexers.
Expand Down Expand Up @@ -46,13 +50,13 @@ pub fn run(args: AttachArgs) -> Result<i32> {
"{warn}{bold}sinteractive:{reset}{warn} you have {} running sessions — pick one:{reset}",
many.len()
);
for r in many {
for (i, r) in many.iter().enumerate() {
let name = parse_comment(&r.comment)
.flatten()
.unwrap_or_else(|| r.job_id.to_string());
eprintln!(
" sinteractive attach {id}{name:<18}{reset} {dim}# job {} on {}, up {}{reset}",
r.job_id, r.node, r.elapsed
" {dim}{:>2}{reset} sinteractive attach {id}{name:<18}{reset} {dim}# job {} on {}, up {}{reset}",
i + 1, r.job_id, r.node, r.elapsed
);
}
return Ok(1);
Expand All @@ -61,7 +65,7 @@ pub fn run(args: AttachArgs) -> Result<i32> {
}
};

let job_id = match ctx.resolve(Some(&target)) {
let job_id = match ctx.resolve_with_index(Some(&target)) {
Ok(id) => id,
Err(e) => {
eprintln!("{err}{bold}sinteractive:{reset}{err} {e}{reset}");
Expand Down
6 changes: 5 additions & 1 deletion crates/sint/src/commands/attach_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ pub fn run(session: &str) -> Result<i32> {
// terminal alone.
let detached = detach_other_clients(&zellij, session);
if detached > 0 {
let p = Palette::for_fd(ColorMode::from_env(), 2);
// No live terminal query here: the real zellij client is exec'd on
// this same terminal a few lines below, and a reply that arrives
// late would land as raw bytes in its freshly drawn pane instead of
// at a shell prompt.
let p = Palette::for_fd_no_query(ColorMode::from_env(), 2);
let s = if detached == 1 { "" } else { "s" };
eprintln!(
"{}Detached {detached} other client{s} from this session.{}",
Expand Down
2 changes: 1 addition & 1 deletion crates/sint/src/commands/cancel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn cancel_job(ctx: &Ctx, job_id: u64) -> CancelResult {

pub fn run(args: CancelArgs) -> Result<i32> {
let ctx = Ctx::new();
let Some(job_id) = ctx.resolve_reporting(Some(&args.target))? else {
let Some(job_id) = ctx.resolve_reporting_with_index(Some(&args.target))? else {
return Ok(1);
};

Expand Down
50 changes: 48 additions & 2 deletions crates/sint/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::process::{Command, Stdio};
use anyhow::{anyhow, Result};
use sint_core::color::Palette;
use sint_core::config::{ColorMode, Config};
use sint_core::session::{resolve_target, sessions_only, SessionInfo, Target};
use sint_core::session::{resolve_index, resolve_target, sessions_only, SessionInfo, Target};
use sint_core::slurm::squeue::JobRow;
use sint_core::slurm::{Slurm, SlurmError};
use sint_core::state::StateDir;
Expand Down Expand Up @@ -37,6 +37,17 @@ impl Ctx {
Palette::for_fd(ColorMode::from_env(), fd)
}

/// [`Ctx::palette`], but never risking the live terminal query (see
/// [`Palette::for_fd_no_query`]). For narration right before exec'ing
/// into an interactive zellij client (`attach`, launch-and-attach): a
/// late reply to the query would otherwise land as raw escape bytes in
/// that client's freshly drawn pane rather than at a shell prompt — the
/// "control characters right after attaching" a laggy reconnect can
/// trigger.
pub fn palette_no_query(&self, fd: i32) -> Palette {
Palette::for_fd_no_query(ColorMode::from_env(), fd)
}

/// The user's RUNNING+PENDING sinteractive sessions.
pub fn sessions(&self) -> Result<Vec<JobRow>> {
let rows = self.slurm.my_jobs(&["RUNNING", "PENDING"])?;
Expand All @@ -58,6 +69,11 @@ impl Ctx {

/// Resolve an optional CLI target: explicit JOBID/NAME, else the current
/// session (`SINTERACTIVE_JOB_ID`), else an error naming the fix.
///
/// A bare number passes through unchecked — deliberately no Slurm call,
/// so a command that only needs the id (`events`) stays Slurm-free.
/// [`Ctx::resolve_with_index`] is the variant that also reads a number
/// as a `list` position, for callers that touch Slurm anyway.
pub fn resolve(&self, target: Option<&str>) -> Result<u64> {
match target {
Some(t) => {
Expand All @@ -75,11 +91,41 @@ impl Ctx {
}
}

/// [`Ctx::resolve`], but a bare number that no running session actually
/// has is read as a 1-based position in `list`'s order instead of a
/// literal job id (see [`resolve_index`]). Every caller here already
/// talks to Slurm for other reasons (`attach` checks the job is
/// RUNNING, `cancel` runs `scancel`), so the extra lookup adds no new
/// round trip class — unlike [`Ctx::resolve`], which callers such as
/// `events` rely on to stay Slurm-free for a bare job id.
pub fn resolve_with_index(&self, target: Option<&str>) -> Result<u64> {
if let Some(t) = target {
if let Target::JobId(id) = Target::parse(t) {
let running = self.running_sessions()?;
return Ok(resolve_index(id, &running).unwrap_or(id));
}
}
self.resolve(target)
}

/// [`Ctx::resolve`], reporting a no-match or ambiguous name on stderr in
/// the 0.x wording (script line 2152) and returning `Ok(None)`; the
/// caller then exits 1. A Slurm failure is still an `Err`.
pub fn resolve_reporting(&self, target: Option<&str>) -> Result<Option<u64>> {
match self.resolve(target) {
self.report_resolve(self.resolve(target))
}

/// [`Ctx::resolve_reporting`], but resolving through
/// [`Ctx::resolve_with_index`] so a bare number may also be a `list`
/// position.
pub fn resolve_reporting_with_index(&self, target: Option<&str>) -> Result<Option<u64>> {
self.report_resolve(self.resolve_with_index(target))
}

/// The error reporting shared by [`Ctx::resolve_reporting`] and
/// [`Ctx::resolve_reporting_with_index`].
fn report_resolve(&self, result: Result<u64>) -> Result<Option<u64>> {
match result {
Ok(id) => Ok(Some(id)),
Err(e) if e.downcast_ref::<SlurmError>().is_some() => Err(e),
Err(e) => {
Expand Down
4 changes: 3 additions & 1 deletion crates/sint/src/commands/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,9 @@ pub(crate) fn launch(ctx: &Ctx, args: LaunchArgs, created: Option<bool>) -> Resu
Launched::Failed(code) => return Ok(code),
Launched::Ready { job_id, node } => (job_id, node),
};
let p = ctx.palette(2);
// The interactive path below hands this same terminal to an ssh'd
// `__attach`, so no live terminal query — see `Ctx::palette_no_query`.
let p = ctx.palette_no_query(2);
let (reset, bold, key, id, ok) = (&p.reset, &p.bold, &p.key, &p.id, &p.ok);
let name = args.name.clone().filter(|n| !n.is_empty());
let attach_target = name.unwrap_or_else(|| job_id.to_string());
Expand Down
Loading
Loading