diff --git a/client-core/src/transcript_fold.rs b/client-core/src/transcript_fold.rs index 1c02a8d..cc76316 100644 --- a/client-core/src/transcript_fold.rs +++ b/client-core/src/transcript_fold.rs @@ -112,6 +112,13 @@ pub enum TranscriptItem { ClearedNote { seq: u64, }, + /// The account's usage limit stopped the turn; `resets_at` is epoch + /// seconds when the dialect said when it lifts (`LimitNote` in + /// `TranscriptItems.kt`). + LimitNote { + seq: u64, + resets_at: Option, + }, CompactedNote { seq: u64, pre_tokens: Option, @@ -131,6 +138,7 @@ impl TranscriptItem { | Self::CommandRow { seq, .. } | Self::Note { seq, .. } | Self::ClearedNote { seq } + | Self::LimitNote { seq, .. } | Self::CompactedNote { seq, .. } => *seq, Self::QuestionCard(card) => card.seq, } @@ -525,6 +533,14 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec { + let mut items = items.to_vec(); + items.push(TranscriptItem::LimitNote { + seq, + resets_at: *resets_at, + }); + items + } Event::Compacted { pre_tokens, post_tokens, diff --git a/event-model/src/lib.rs b/event-model/src/lib.rs index 9b96ad5..4c40c11 100644 --- a/event-model/src/lib.rs +++ b/event-model/src/lib.rs @@ -305,6 +305,25 @@ pub enum Event { /// it, which is why this is written down rather than left to be inferred /// from a second example that does not exist. Cleared, + /// The account behind this session has no quota left, so the turn stopped + /// without finishing. + /// + /// Its own event rather than an [`Event::Error`] carrying the dialect's + /// sentence, because two things act on it that cannot read English: the + /// transcript draws it as a state the session is in rather than as a + /// failure of something it did, and `crate::resume` schedules the message + /// that picks the work back up. Recognising it belongs to the driver, which + /// is the only layer that knows its dialect's wording -- above here nothing + /// matches on strings. + /// + /// `resets_at` is epoch seconds, and `None` is a real state: the dialect + /// said the limit was hit without saying when it lifts. Nothing here + /// invents one -- what the wait is actually decided against is the usage + /// endpoint, and this is the hint that starts the waiting. + LimitReached { + #[serde(default, skip_serializing_if = "Option::is_none")] + resets_at: Option, + }, Error { message: String, }, diff --git a/iris/transcript-ui/src/row.rs b/iris/transcript-ui/src/row.rs index c082397..92581ae 100644 --- a/iris/transcript-ui/src/row.rs +++ b/iris/transcript-ui/src/row.rs @@ -60,6 +60,15 @@ fn item_content(item: &TranscriptItem) -> (Option<&str>, String) { TranscriptItem::PeerNote { from, text, .. } => (Some(from.as_str()), text.clone()), TranscriptItem::Note { text, .. } => (None, text.clone()), TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()), + // Epoch seconds as-is until the port has a relative-time formatter + // (P1); the Compose `LimitRow` draws it as a countdown. + TranscriptItem::LimitNote { resets_at, .. } => ( + None, + match resets_at { + Some(at) => format!("_Usage limit reached; resets at {at:.0} (epoch seconds)._"), + None => "_Usage limit reached._".to_string(), + }, + ), TranscriptItem::CompactedNote { pre_tokens, post_tokens,