1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
//! **A sent message between frames** (DESIGN §4.45 item 4; bl-ca3d): what
//! this seat composed and has not yet seen come back as the engine's own row.
//!
//! **The record is the ruling's own sentence** — *there should never be a
//! half-second where your message is nowhere*. The box clears on firing and
//! the read that carries the words lands at ask cadence, so between the two
//! the words would be in no pane at all; the record is what the transcript
//! paints over that gap (`crate::ui::chat::rows::whole`). It is a gesture
//! this seat made, [`Model::tabs`]'s class of fact — per run, never in the
//! place file, keyed by conversation so a tab change keeps each with its own
//! transcript.
//!
//! **Three endings, each decided where the fact arrives**, and none in the
//! projection, which paints what stands and mutates nothing: the receipt
//! ([`Model::acknowledged`]) changes the ink; the committed transcript
//! ([`Model::carried`]) retires the record the frame it holds the words;
//! a refusal, or an act that never left this box ([`Model::unsent`]), drops
//! the record and the words are the operator's again.
use super::{Model, Posted};
use crate::reply::transcript::{EntryKind, Transcript};
use crate::reply::{Read, Reply};
/// **One message this seat sent**, standing until the engine's row replaces
/// it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sent {
/// The conversation it was addressed to: the one its row paints under.
pub conversation: String,
/// The words, verbatim — what the row paints and what the transcript's
/// delivery is matched against.
pub words: String,
/// **How many entries the committed transcript held when it was
/// composed.** The delivery that carries the words can only stand past
/// this index, so an older deposit of the same words never retires it —
/// save where no read had landed yet, when the count is zero and the
/// match is as wide as the transcript (a cost bounded by one ask
/// cadence and stated here).
pub after: usize,
/// Whether the act's receipt landed `ok`: the ink, and nothing else.
pub acked: bool,
}
/// **The two deposits**: the acts whose words are the operator's own row on
/// the transcript, and the only acts a record is kept for.
pub(super) const DEPOSITS: [&str; 2] = [crate::verbs::MESSAGE.word, crate::verbs::INTERRUPT.word];
impl Model {
/// **Compose one deposit and record it as sent**, one act — the one door
/// the box (`crate::ui::composer::fire`) and the slash line
/// (`crate::ui::slash::fire`) both go through, so there is no deposit on
/// this window that is not on the glass the frame it is composed.
///
/// `door` is `crate::verbs::message` or `crate::verbs::interrupt`: the
/// two take the same three strings and differ only in the word, which is
/// what a function pointer carries.
pub fn deposit(
&mut self,
door: fn(String, String, String) -> serde_json::Value,
workspace: String,
agent: String,
words: String,
) {
self.sent.push(Sent {
conversation: agent.clone(),
words: words.clone(),
after: self.transcript.entries.len(),
acked: false,
});
self.outbox.push(Posted::act(door(workspace, agent, words)));
}
/// **The records addressed to the selected conversation**, oldest first:
/// what the transcript paints after its committed rows.
pub(crate) fn sent_here(&self) -> impl Iterator<Item = &Sent> {
self.sent
.iter()
.filter(|sent| Some(sent.conversation.as_str()) == self.conversation.as_deref())
}
/// **A deposit's receipt** (`super::absorb`, the `message` and `interrupt`
/// ops), answered to the oldest record not yet acknowledged — the acts
/// cross one channel in the order they were posted, and a receipt names
/// no conversation of its own. An `ok` outcome marks it acknowledged,
/// which is the ink changing and nothing else: the row stays until the
/// transcript carries the words, because a row retired on the receipt
/// would be a message nowhere for exactly the half-second the ruling
/// forbids. A refusal drops the record and the words go back in the box.
/// Anything else — a failed run, a frame this seat cannot read — is the
/// bar's sentence, and the row stands in the pending ink beside it.
pub(super) fn acknowledged(&mut self, op: &str, read: &Read) {
if !DEPOSITS.contains(&op) {
return;
}
let Some(at) = self.sent.iter().position(|sent| !sent.acked) else {
return;
};
match read {
Read::Answer(Reply::Outcome(run)) if run.ok() => {
if let Some(sent) = self.sent.get_mut(at) {
sent.acked = true;
}
}
Read::Refusal(_) => {
let sent = self.sent.remove(at);
self.restore(sent.words);
}
_ => {}
}
}
/// **An act that never left this box** ([`Model::acted`]): the record goes
/// with it, because no receipt and no read will ever retire it, and the
/// words are `refund`'s. One that crossed and was never answered keeps
/// its row in the pending ink beside the in-doubt notice — the engine
/// may have it, and a row that vanished would say it does not.
pub(super) fn unsent(&mut self, op: &str, reach: &crate::channel::Reach) {
if !DEPOSITS.contains(&op) || reach.crossed() {
return;
}
if let Some(at) = self.sent.iter().position(|sent| !sent.acked) {
self.sent.remove(at);
}
}
/// **The committed transcript landed** (`super::absorb`): every record
/// addressed to the selected conversation whose words it now carries —
/// an operator delivery past the recorded length saying exactly them —
/// is dropped the same frame, so the words are the engine's row and never
/// two rows. Records for other conversations stand: the read is the
/// selected one's.
pub(super) fn carried(&mut self, transcript: Transcript) {
let selected = self.conversation.clone();
self.sent.retain(|sent| {
selected.as_deref() != Some(sent.conversation.as_str()) || !carries(&transcript, sent)
});
self.transcript = transcript;
}
/// **Refused words go back in the box, ahead of whatever is typed.** The
/// start's rule (`Model::refund`, bl-b180) gives words back only to an
/// empty box, because a goal overwriting a newer draft loses the newer
/// of the two; a refused message was already on the glass as a row, and
/// the box is the only place left for it — so it goes in front, with a
/// line break, and neither the refused words nor the draft is lost.
fn restore(&mut self, words: String) {
self.draft = if self.draft.trim().is_empty() {
words
} else {
format!("{words}\n{}", self.draft)
};
}
}
/// Whether `transcript` holds `sent`'s words as an operator's delivery past
/// the recorded length.
fn carries(transcript: &Transcript, sent: &Sent) -> bool {
transcript.entries.iter().skip(sent.after).any(|entry| {
matches!(
&entry.kind,
EntryKind::Delivered { sender, body, .. }
if crate::ui::chat::rows::OPERATOR.contains(&sender.as_str()) && *body == sent.words
)
})
}
#[cfg(test)]
mod tests;