quvyta-cli 0.1.0-alpha.5

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! `run_command`: `sh -c` in the workspace root, with a time limit and a way to stop it.

use std::time::Duration;

use qframe::runtime::{Keep, Process, ProcessOutcome};

use super::Outcome;
use super::workspace::Workspace;

/// How long a command may run before it is stopped.
pub(super) const LIMIT: Duration = Duration::from_secs(120);
/// At most this many bytes of output are returned, from the end.
pub(super) const MAX_OUTPUT_BYTES: usize = 30 * 1024;
/// At most this many lines of output are returned, from the end.
pub(super) const MAX_OUTPUT_LINES: usize = 400;
/// How long the output may stay open after the command itself has ended, held by something it
/// left in the background, before that is stopped with it; also how long a stopped command is
/// given to end by itself after `TERM` before it is killed.
const GRACE: Duration = Duration::from_secs(2);

pub(super) fn run(workspace: &Workspace, command: &str, cancelled: &dyn Fn() -> bool, limit: Duration) -> Outcome {
    run_watching(workspace, command, cancelled, limit, &mut |_| {})
}

/// [`run`], handing the end of the output to `on_tail` while the command runs, at most every
/// tenth of a second, so the person sees a build or a test run go by.
pub(super) fn run_watching(
    workspace: &Workspace,
    command: &str,
    cancelled: &dyn Fn() -> bool,
    limit: Duration,
    on_tail: &mut dyn FnMut(&str),
) -> Outcome {
    if command.trim().is_empty() {
        return Outcome::failed("`run_command` needs a non-empty `command`.".to_owned());
    }
    // Standard output and error share one pipe, so their lines arrive in the order written. The
    // command gets no input and a group of its own, so stopping it also stops what it started.
    let keep = Keep::bytes(MAX_OUTPUT_BYTES).lines(MAX_OUTPUT_LINES).limit(limit).after_exit(GRACE);
    let collected = Process::new("sh").arg("-c").arg(command).dir(workspace.root()).no_stdin().collect_watching(
        keep,
        cancelled,
        |tail| on_tail(tail),
    );
    let collected = match collected {
        Ok(collected) => collected,
        Err(error) => return Outcome::failed(format!("The command could not be started: {error}.")),
    };
    let output = shown(&collected.text, collected.trimmed);
    if collected.cancelled {
        return Outcome::done(format!("{output}\nStopped: the person cancelled the command."));
    }
    if collected.timed_out {
        return Outcome::done(format!("{output}\nStopped: the command ran longer than {} seconds.", limit.as_secs()));
    }
    let ending = match collected.outcome {
        ProcessOutcome::Finished { code: Some(code) } => format!("Exit code: {code}."),
        ProcessOutcome::Finished { code: None } => "Ended by a signal.".to_owned(),
        ProcessOutcome::Cancelled => "Stopped: the person cancelled the command.".to_owned(),
    };
    Outcome::done(format!("{output}\n{ending}"))
}

/// The end of the output as the model reads it, with a note when anything was cut.
pub(super) fn shown(text: &str, trimmed: bool) -> String {
    let lines: Vec<&str> = text.lines().collect();
    if lines.is_empty() {
        return "(no output)".to_owned();
    }
    let shown = lines.join("\n");
    if trimmed { format!("(Output cut: only the last {} lines are shown.)\n{shown}", lines.len()) } else { shown }
}