lernie 0.1.113

lernie: the operator seat — the window and wire client for a yog server
//! **The chat pane as data** — every entry of a transcript, and the live fold,
//! as the rows the pane paints.
//!
//! Split from [`super`] at the design-time budget on the seam that module's
//! own doc draws: this is the **projection**, a pure function of the
//! transcript and the fold, and [`super`] is the pane that paints it. The two
//! change for different reasons — an entry kind the server grows changes this,
//! a control the pane grows changes that — and the split is what keeps a
//! reply-vocabulary change from touching any paint at all.
//!
//! **Nothing is dropped, including what nothing could parse.** An entry the
//! server could not read is surfaced as its raw bytes, and an entry of a kind
//! this build does not know is surfaced as its own word beside them — the
//! reply vocabulary's rung 3, on the glass. A transcript that quietly skipped
//! an entry would be a conversation the operator reads as shorter than it was,
//! which is the one failure a transcript must not have.
//!
//! The one thing that is **not** a row is a half of a turn with nothing in it
//! — see [`half`], which is that rule's one home. An empty half is not
//! something the operator was not shown; it is something that was never said.

use super::fold;
use crate::ui::theme::Speaker;

/// The turn that has not settled yet.
mod live;
/// A call and its result, met on one row.
mod pair;
/// Whose weight a row is said in, and which bubble it stands on.
mod role;
/// The row itself — what one line of the pane is made of (bl-1d79).
mod row;
pub use row::Row;
/// What this seat sent, as rows after the committed ones.
pub mod sent;

use crate::reply::stream::Stream;
use crate::reply::transcript::{Block, Entry, EntryKind, Transcript};
use live::{half, live_rows, streaming};
pub(crate) use role::OPERATOR;
use role::delivered;

/// One painted row: who is speaking, and what they said.
///
/// **[`Row::fold`] is what makes a machine's answer machinery** (bl-90d0). It
/// is `None` for everything a person or a model said, whatever its length — a
/// long prose answer is the thing the pane is opened for — and `Some` only for
/// a tool result big enough to have something to hide. So the pane folds by
/// KIND and never by size, which is the distinction that matters: a
/// seven-hundred-line `bl --help` is not a long answer, it is bookkeeping in
/// front of one.
/// **What kind of row it is**, which is what the glass groups by (DESIGN
/// §4.42, item 4): something said, or a step of the trail — a call, its
/// result, or reasoning — which the paint gathers under one fold.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Kind {
    #[default]
    Said,
    Call,
    /// A call whose result has landed and been folded into its row
    /// ([`pair`]).
    Answered,
    Result,
    Thinking,
}

/// **Who a wound's row is said by**: nobody — it is the conversation's
/// state, so it wears the error rule and a word for what happened rather
/// than the server's virtual entry name.
pub const WOUND: &str = "it stopped";

/// **The whole pane as data.**
///
/// The live fold **is a newer reading of the committed read's `Streaming`
/// entry, and it paints only in that entry's place** (bl-f6b5). The tail
/// reaches a seat by two routes at two cadences — the pull read folds one on
/// at ask cadence, the follow lane delivers a newer one at write cadence — and
/// *the newest fold wins* is the only reconciliation either needs.
///
/// **So a transcript with no `Streaming` entry has no turn in flight, and the
/// fold is not painted at all.** The server's streaming entry rides the open
/// response (REMOTE §5.5), so its absence is the committed read saying the
/// response closed — the turn is a `model` entry now, and a fold appended
/// after it painted the answer twice, `«live»` under the committed one, for as
/// long as the lane's last fold stood in [`crate::ui::Model::live`]. The rule
/// is derived from the read rather than kept by clearing the field when the
/// lane ends: a lane end is not the commit (the read can land while the lane
/// is still open), while the read IS the commit, whichever order the two
/// routes arrive in — and a field nothing writes cannot go stale. The cost
/// is bounded and stated: a turn that has begun streaming but that no pull
/// read has yet seen paints its tail from the next read on, one ask cadence
/// late, rather than early.
pub fn rows(transcript: &Transcript, live: Option<&Stream>) -> Vec<Row> {
    let mut rows = pair::met(
        transcript
            .entries
            .iter()
            .flat_map(|entry| {
                let rows = match (&entry.kind, live) {
                    (EntryKind::Streaming { .. }, Some(fold)) => streaming(fold),
                    _ => entry_rows(entry),
                };
                rows.into_iter().map(|row| row.from(&entry.name))
            })
            .collect(),
    );
    let running = transcript
        .entries
        .iter()
        .any(|entry| matches!(entry.kind, EntryKind::Streaming { .. }));
    role::settle(&mut rows, running);
    rows
}

/// The two words a settled call is said in, on both routes.
pub(super) const RETURNED: &str = "returned";
pub(super) const FAILED: &str = "failed";

/// One entry's rows.
fn entry_rows(entry: &Entry) -> Vec<Row> {
    match &entry.kind {
        EntryKind::Delivered {
            sender,
            sender_name,
            epitaph,
            body,
        } => {
            let who = crate::reply::transcript::said_by(sender, sender_name.as_deref());
            let weight = delivered(sender, epitaph.as_ref());
            vec![Row::plain(
                match epitaph {
                    Some(word) => format!("{who} ({word})"),
                    None => who,
                },
                body.clone(),
                weight,
            )]
        }
        EntryKind::Model {
            model_id, blocks, ..
        } => blocks.iter().filter_map(|b| block(model_id, b)).collect(),
        EntryKind::ToolResult {
            tool_use_id,
            content,
            is_error,
        } => vec![Row::result(
            tool_use_id,
            content.clone(),
            *is_error,
            // **The one row in this pane that hides anything**, and only when
            // there is something to hide (`fold`).
            fold::of(content),
        )],
        EntryKind::Streaming { thinking, text } => live_rows(thinking, text),
        EntryKind::Compacted {
            first,
            last,
            summary,
        } => vec![Row::plain(
            format!("compacted {first}–{last}"),
            summary.clone(),
            Speaker::Peer,
        )],
        // The two unreadables, held apart: the server could not read this one,
        // and this build does not know that one. Only the second is fixed by an
        // upgrade, so they must not read alike.
        EntryKind::Wounded {
            wound,
            reason,
            auth_row,
        } => vec![Row {
            failed: true,
            ..Row::plain(
                WOUND.to_owned(),
                crate::reply::transcript::wounded(wound, reason.as_deref(), auth_row.as_deref()),
                Speaker::Peer,
            )
        }],
        EntryKind::Raw => vec![Row::plain(
            format!("{} (unparsed)", entry.name),
            entry.raw.clone(),
            Speaker::Peer,
        )],
        EntryKind::Unknown(word) => vec![Row::plain(
            format!(
                "{} ({word}, which this version of lernie cannot read)",
                entry.name
            ),
            entry.raw.clone(),
            Speaker::Peer,
        )],
    }
}

/// One content block. Reasoning is a **row**, not a spinner: a badge that never
/// grows cannot tell a model thinking hard from a driver that has hung.
///
/// The two halves of a turn go through [`half`], so the committed path obeys
/// the rule the live one states. The other two blocks do not: a tool call with
/// an empty input still happened, and an unreadable block is deliberately blank
/// — dropping either would be the opposite defect, a transcript the operator
/// reads as shorter than it was.
fn block(model_id: &str, block: &Block) -> Option<Row> {
    match block {
        Block::Text(text) => half(model_id, "", text),
        Block::Thinking(text) => {
            half(model_id, "thinking", text).map(|row| row.as_(Kind::Thinking))
        }
        Block::ToolUse { id, name, input } => Some(Row::call(name, input, id)),
        Block::Unknown(word) => Some(Row::plain(
            format!("{model_id} ({word}, which this version of lernie cannot read)"),
            String::new(),
            Speaker::Model,
        )),
    }
}