polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! The single message type that flows through the whole app. Terminal events,
//! timers, and background tasks are all normalised into [`Action`]s before
//! they reach components — that uniformity is the point of the pattern.

use ratatui::crossterm::event::{KeyEvent, MouseEvent};

use crate::components::approvals::{ApprovalOutcome, ApprovalView};
use crate::components::fleet::ConversationRow;
use crate::components::questions::{QuestionOutcomeView, QuestionView};
use crate::components::tools::ToolView;
use crate::components::transcript::MessageLine;

/// The top-level panes of the cockpit. Selection cycles between them and
/// drives which component receives focused key input.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Pane {
    /// Fleet list: all conversations and their lifecycle status.
    Fleet,
    /// Transcript: the rendered message stream of the selected conversation.
    Transcript,
    /// Approvals: pending HITL tool-call decisions for the selection.
    Approvals,
    /// Tools: the tool catalogue / per-conversation enabled tools.
    Tools,
    /// Questions (`#1660`): pending `ask_question` prompts across the fleet —
    /// the question-pause SIBLING of `Approvals`, not a reuse of it.
    Questions,
}

impl Pane {
    /// All panes in tab order — drives the top nav bar and `Tab` cycling.
    pub(crate) const ALL: [Self; 5] = [
        Self::Fleet,
        Self::Transcript,
        Self::Approvals,
        Self::Tools,
        Self::Questions,
    ];

    /// This pane's position in [`Pane::ALL`] (the selected tab index).
    #[must_use]
    pub(crate) const fn index(self) -> usize {
        match self {
            Self::Fleet => 0,
            Self::Transcript => 1,
            Self::Approvals => 2,
            Self::Tools => 3,
            Self::Questions => 4,
        }
    }

    /// Short label shown in the top nav bar (prefixed with its `1`-`5` key).
    #[must_use]
    pub(crate) const fn title(self) -> &'static str {
        match self {
            Self::Fleet => "1 Fleet",
            Self::Transcript => "2 Transcript",
            Self::Approvals => "3 Approvals",
            Self::Tools => "4 Tools",
            Self::Questions => "5 Questions",
        }
    }
}

/// A decision a user makes on a pending approval, before it is submitted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ApprovalDecision {
    /// Turn that emitted this occurrence of `request_id`.
    pub turn_id: String,
    /// The pending tool-call id being answered (== `request_id`).
    pub request_id: String,
    /// The conversation the approval belongs to (required by the wire).
    pub conversation_id: String,
    /// Bound tool name (for the local v2 signature self-check).
    pub tool_name: String,
    /// Bound `args_json` (for the local v2 signature self-check).
    pub args_json: String,
    /// `true` to authorise the tool call, `false` to deny.
    pub approved: bool,
    /// Free-form rationale persisted alongside the decision.
    pub reason: String,
    /// Short-lived signed capability (`#787`) required by
    /// `ApprovalService.Respond`.
    pub resolve_token: String,
}

/// A decision a user makes on a pending `ask_question` question (`#1660`),
/// before it is submitted — the question-pause SIBLING of
/// [`ApprovalDecision`], not a reuse of it: `ask_question` offers a fixed,
/// model-proposed option set (or an explicit decline), never an
/// approve/deny/session/abort/defer decision, so there is no `reason`/
/// `resolve_token` shape to mirror — just which option (or none).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuestionDecision {
    /// Turn that emitted this question occurrence (`#2523`).
    pub turn_id: String,
    /// The `ask_question` call id this question came from.
    pub call_id: String,
    /// This question's position within its call's `questions` array.
    pub index: u32,
    /// The conversation this question belongs to (required by the wire).
    pub conversation_id: String,
    /// `Some(i)` picks the option at index `i`; `None` declines.
    pub selected_index: Option<u32>,
    /// The chosen option's label, denormalized off the same item the
    /// component already holds — mirrors `ApprovalDecision::tool_name`/
    /// `args_json` carrying data the app loop would otherwise have to look
    /// back up. Empty for a decline.
    pub selected_label: String,
    /// Short-lived signed capability required by `QuestionService.Respond`.
    pub answer_token: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Action {
    // ---- loop-level (handled by `App::update`) ----
    /// Domain time tick (animations, poll cadence). Driven by the tick interval.
    Tick,
    /// Request a single redraw. The ONLY trigger for `terminal.draw`.
    Render,
    /// Pattern 3: terminal was resized (`SIGWINCH` surfaces as `Event::Resize`).
    Resize(u16, u16),
    /// Tear down the event loop.
    Quit,
    /// Suspend to the shell (Ctrl-Z / `SIGTSTP`); the cockpit resumes on `fg`.
    Suspend,

    // ---- navigation / selection ----
    /// Move focus to a pane.
    Nav(Pane),
    /// Select a conversation by id (drives transcript/approvals/tools loads).
    Select(String),

    // ---- fleet data ----
    /// Full fleet snapshot loaded (initial list or a refresh).
    FleetLoaded(Vec<ConversationRow>),
    /// A single conversation row changed (kube watch delta).
    FleetDelta(ConversationRow),

    // ---- transcript data ----
    /// Full prior transcript history for the selected conversation.
    TranscriptHistory {
        /// The conversation these lines belong to.
        conversation_id: String,
        /// Rendered message lines, oldest first.
        lines: Vec<MessageLine>,
    },

    // ---- approvals (HITL) ----
    /// A pending approval surfaced for a conversation (turn paused on a
    /// tool-call awaiting a decision).
    ApprovalPending(ApprovalView),
    /// The user decided on a pending approval (pre-submit).
    ApprovalDecide(ApprovalDecision),
    /// The control plane persisted a submitted approval decision.
    ApprovalSubmitted {
        /// The conversation containing the occurrence.
        conversation_id: String,
        /// The turn that emitted this occurrence of `request_id`.
        turn_id: String,
        /// The request id that was answered.
        request_id: String,
        /// Whether the control plane persisted the response.
        persisted: bool,
        /// The signed outcome to fold onto the item, present when `persisted`.
        /// Built from the `ApprovalService.Respond` reply (signer pk +
        /// signature) so the decided/signed state is reflected immediately,
        /// without depending on a later forensics re-poll.
        outcome: Option<ApprovalOutcome>,
    },

    // ---- tools ----
    /// The tool catalogue / enabled-tool view loaded for a conversation.
    ToolsLoaded(Vec<ToolView>),

    // ---- questions (ask_question, `#1660`) ----
    /// A pending question surfaced for a conversation (turn paused on
    /// `ask_question` awaiting an answer) — the question-pause SIBLING of
    /// `ApprovalPending` above, not a reuse of it.
    QuestionPending(QuestionView),
    /// The user decided on a pending question (pre-submit).
    QuestionDecide(QuestionDecision),
    /// The control plane persisted a submitted question answer.
    QuestionSubmitted {
        /// The `ask_question` call id that was answered.
        call_id: String,
        /// The question index within that call.
        index: u32,
        /// Whether the control plane persisted the answer.
        persisted: bool,
        /// The signed outcome to fold onto the item, present when
        /// `persisted` — mirrors `ApprovalSubmitted::outcome`'s role.
        outcome: Option<QuestionOutcomeView>,
    },

    // ---- raw input + faults ----
    /// Raw key input, mapped 1:1 from crossterm.
    Key(KeyEvent),
    /// Raw mouse input, mapped 1:1 from crossterm.
    Mouse(MouseEvent),
    /// A background task or adapter failed; carries a user-visible message.
    Error(String),
}