trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! `tm projects` 4-pane TUI skeleton — the project control plane (#2118, DOC-35 §5).
//!
//! Why: operators managing several projects' worth of managed sessions need
//! one screen that shows every registered project's aggregate health
//! (Projects pane), drills into one project's sessions (Sessions pane), and
//! previews the focused session's static record fields (Activity pane
//! skeleton — live `/activity` polling is #2119) — with the lifecycle verbs
//! (launch/kill/resume/decommission/attach) one keystroke away. This is the
//! FULL 4-pane layout landing as v1 (the owner resolved #2118 to skip a
//! 2-pane MVP).
//! What: [`run`] is the `tm projects` (bare, TTY) entry point — it primes the
//! state with one poll, then hands off to [`run_loop`] with a panic-safe
//! terminal guard (mirroring `tui::coordinator::run`'s `TerminalGuard`
//! pattern, since this screen also needs to restore the terminal on an
//! unexpected panic, not just a clean return). The event loop itself follows
//! the same draw → poll-key → dispatch → maybe-repoll shape as
//! `tui::coordinator::run_loop`.
//! Test: the pure pieces (state, poll projections, key routing, pane content)
//! are unit-tested in their own modules; this terminal-bound loop is
//! exercised by launching the TUI.

pub mod actions;
pub mod events;
pub mod layout;
pub mod panes;
pub mod poll;
pub mod state;

#[cfg(test)]
mod tests;

use std::io::Stdout;
use std::time::{Duration, Instant};

use crossterm::event::{self, Event};
use ratatui::{Terminal, backend::CrosstermBackend};

use crate::client::DaemonClient;
use events::handle_key;
use poll::project_ctl_poll_daemon;
use state::ProjectCtlState;

// #7224: the panic-safe terminal guard moved to `tui::terminal`, shared with
// the coordinator screen and the `tm ls` session TUI.
use crate::tui::terminal::TerminalGuard;

/// How long [`run_loop`] blocks waiting for a key before redrawing.
///
/// Why: matches the coordinator/dashboard screens' input cadence — short
/// enough to feel responsive, independent of the daemon refresh cadence.
const KEY_POLL: Duration = Duration::from_millis(50);

/// Launch the `tm projects` 4-pane TUI against `url`, polling every `interval_ms`.
///
/// Why: the bare-`tm projects`-on-a-TTY entry point (#2118); `main.rs`'s
/// bare-dispatch branch calls this after confirming stdout is a TTY.
/// What: builds a [`DaemonClient`] for `url`, primes the state with one poll
/// (so the first frame is never an empty flash), enters raw mode + the
/// alternate screen, installs a [`TerminalGuard`], then runs [`run_loop`] on
/// the `interval_ms` cadence.
/// Test: terminal glue is exercised by launching the TUI; the loop's pure
/// pieces are unit-tested in their own modules.
pub async fn run(url: String, interval_ms: u64) -> anyhow::Result<()> {
    run_focused(url, interval_ms, None).await
}

/// Launch the multipane dashboard, optionally pre-selecting one session.
///
/// Why (#6483): `tm tui` and `tm attach <target>` now open THIS surface by
/// default instead of the single-pane coordinator chat. `tm attach` resolves a
/// session id first and expects the TUI to open on that session, so the entry
/// point needs the same `focus_id` seam `tui::run_focused` already had.
/// What: same client/prime/terminal-guard sequence as [`run`]; after the
/// priming poll it calls [`ProjectCtlState::focus_session`], which selects the
/// owning project and session row (and is a no-op for an id the fleet does not
/// know). A missed focus sets a notice rather than failing the launch — the
/// session may simply not have been polled yet.
/// Test: the focus selection itself is unit-tested
/// (`focus_session_selects_owning_project_and_row`); this terminal glue is
/// exercised by launching the TUI.
pub async fn run_focused(
    url: String,
    interval_ms: u64,
    focus_id: Option<String>,
) -> anyhow::Result<()> {
    run_focused_with_client(DaemonClient::new(url), interval_ms, focus_id).await
}

/// [`run_focused`] against a caller-built client, so a pinned URL (#9556)
/// reaches the poll loop.
pub(crate) async fn run_focused_with_client(
    mut client: DaemonClient,
    interval_ms: u64,
    focus_id: Option<String>,
) -> anyhow::Result<()> {
    let mut state = ProjectCtlState::default();
    project_ctl_poll_daemon(&mut state, &mut client).await;
    if let Some(id) = focus_id.as_deref() {
        // #6483: a `tm attach` target that the priming poll has not surfaced
        // yet leaves the selection alone and says so, rather than silently
        // opening on an unrelated project.
        if state.focus_session(id) {
            state.set_notice(format!("attached to {id}"));
        } else {
            state.set_notice(format!("session {id} not in the polled fleet yet"));
        }
    }

    // The guard restores the terminal on every exit path — normal return AND
    // panic unwind — so it is the SOLE teardown (no manual cleanup follows
    // `run_loop`).
    // #7224: setup and guard both come from `tui::terminal`.
    let mut terminal = crate::tui::terminal::enter()?;
    let _guard = TerminalGuard;

    run_loop(&mut terminal, &mut client, state, interval_ms).await
    // `_guard` drops here (or during an unwind), restoring the terminal.
}

/// The live render + poll + input loop.
///
/// Why: kept separate from [`run`] so terminal setup/teardown wraps it
/// cleanly and the loop body stays focused on draw → poll-key → dispatch →
/// maybe-repoll.
/// What: each iteration draws the frame, polls the keyboard for [`KEY_POLL`]
/// and routes any key through [`handle_key`], awaiting
/// [`actions::dispatch`] for any returned [`events::PendingAction`]. Re-polls
/// the daemon immediately after a successful mutating action (via
/// [`ProjectCtlState::take_repoll`]) or once at least `interval_ms` has
/// elapsed since the last poll. Exits once `should_exit` is set (`q` /
/// Ctrl-C).
async fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    client: &mut DaemonClient,
    mut state: ProjectCtlState,
    interval_ms: u64,
) -> anyhow::Result<()> {
    let interval = Duration::from_millis(interval_ms);
    let mut last_poll = Instant::now();

    loop {
        terminal.draw(|frame| layout::render(frame, &mut state))?;

        if event::poll(KEY_POLL)?
            && let Event::Key(key) = event::read()?
        {
            if let Some(action) = handle_key(&mut state, key) {
                actions::dispatch(&mut state, client, action).await;
            }
            if state.should_exit {
                return Ok(());
            }
        }

        // A requested repoll (post-mutation) or the timer elapsing both mean
        // the same thing: refresh now and restart the interval clock. Merged
        // into one branch (rather than an if/else-if with duplicate bodies)
        // to satisfy clippy::if_same_then_else.
        if state.take_repoll() || last_poll.elapsed() >= interval {
            project_ctl_poll_daemon(&mut state, client).await;
            last_poll = Instant::now();
        }
    }
}