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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
//! The roster, attention strip, and seen-acknowledgement surface of
//! [`AppModel`] (DESIGN §6, §11, §15 Y11).
//!
//! All read paths derive attention from the snapshot map plus the one `ui.json`
//! query the module needs — "is this evidence oid acknowledged?" — so the
//! seen-lookup closure is built here from [`UiState::is_seen`] and handed to the
//! pure [`attention`] functions. The write path is the **acknowledgement
//! state**: focusing an agent ([`focus_agent`](AppModel::focus_agent)) records
//! that agent's current evidence oids as seen (§6), and
//! [`ack_focused`](AppModel::ack_focused) re-records them on every tick it stays
//! focused — the ack is a state, not a one-shot gesture (§6, bl-aa1f), so
//! evidence landing on the conversation you are reading is already seen. Durable
//! and converging (§13.1), never mirrored live focus.
//!
//! The one focus no gesture can set outright is the §3.4 start claim
//! ([`await_conversation`](AppModel::await_conversation)): the conversation a
//! fire focuses does not exist yet, so the claim is held by the minted §3.3
//! name and spent by [`adopt_started`](AppModel::adopt_started).
use super::echo::{Echo, Target};
use super::{AppModel, Focus};
use crate::attention;
use crate::boundary::answer::queue;
use crate::keymap::InspectorTab;
use crate::nav::ws_key;
use crate::ui_state::SeenKind;
use std::path::{Path, PathBuf};
impl AppModel {
/// Focus a workspace by its §3.1 **name** (center-panel target) without
/// selecting an agent — no acknowledgement (§6: focus records seen only for
/// a *selected agent*). The selected inspector tab is sticky across the
/// move (§11).
///
/// The name rather than a path since bl-7407: the wire spelling, so a
/// selection and the gesture it becomes say the same word. A name nothing
/// enumerates simply resolves to nothing at the doors — the general path
/// with an empty set, exactly as an unfetched path did.
pub fn focus_workspace(&mut self, name: &str) {
self.focus = Focus {
ws: Some(name.to_owned()),
agent: None,
tab: self.focus.tab,
};
}
/// Focus an agent — the **acknowledgement** (§6): select it in the inspector
/// and record its current evidence oids as seen, converging the ack across
/// instances (§13.1). The selected inspector tab is sticky (§11). The ack
/// does not end here: [`ack_focused`](Self::ack_focused) keeps it stamped
/// for as long as the agent stays focused.
/// It takes the workspace **path** because the acknowledgement it writes is
/// keyed by one (§4.1 `seen`, durable): the path is the caller's already —
/// off the §6 roster, off the echo, off a search hit — so resolving it back
/// out of a name here would be a join over a fact the caller holds.
pub fn focus_agent(&mut self, ws: &Path, agent_id: &str) {
self.focus = Focus {
ws: Some(crate::naming::leaf(ws)),
agent: Some(agent_id.to_string()),
tab: self.focus.tab,
};
self.record_seen(ws, agent_id);
}
/// Claim the conversation a fired start just minted (§3.4): **a start
/// focuses what it started**, and what it started is a conversation, not
/// merely the workspace it resolved. The claim is held by the minted
/// `conversation` name because that is all the fire knows — the root has no
/// agent id until the detached driver writes `agents/<id>` — and the name is
/// the lernie-stored fact (`--name` at fire, §3.3), which the derivation
/// reads back off every root as its `name_fact`.
/// Spent by [`adopt_started`](Self::adopt_started); RAM only (§13.1),
/// as the focus it becomes is.
///
/// **It carries `goal` with it** (§7.2, bl-915e): a handle that painted no
/// row left the operator's text with no representation anywhere in yog
/// until the driver wrote it, which is what read as the UI waiting for the
/// send. The claim and the echo are one value with one lifetime.
pub(crate) fn await_conversation(&mut self, ws: &Path, conversation: &str, goal: &str) {
self.started = Some(Echo::started(ws, conversation, goal, self.now_unix()));
}
/// Hold the echo a §8.2 `message` leaves (§7.2): the same mechanism one
/// door over — the deposit is piped and its `NNN-user.md` only appears on
/// the driver's next step boundary, so the identical gap was open there.
/// No focus claim rides on it: the operator was already looking at this
/// conversation, and their own message landing must not yank them back from
/// wherever they have since navigated.
pub(crate) fn await_message(&mut self, ws: &Path, agent: &str, content: &str) {
self.started = Some(Echo::messaged(
&self.derived,
ws,
agent,
content,
self.now_unix(),
));
}
/// Carry the one pending value forward against the derivation (§3.4, §7.2)
/// — two moves on one `Echo`, never on two concepts:
///
/// 1. **The claim resolves.** The roster carries the root wearing the
/// minted §3.3 name, so the conversation has an id: focus is spent
/// through [`focus_agent`](Self::focus_agent), the same path the ↓ key
/// lands through (so the arrival acknowledges exactly as any other
/// selection does, §6), and the echo takes that id. The claim is spent
/// once and never again — a resolved target has no name left to match —
/// so the operator's own later selection stands.
/// 2. **The echo retires** when the derivation shows the message it stood
/// in for. Nothing claimed, or nothing landed, holds it: the general
/// path with the file absent, not a wait state — and there is no timeout
/// beside it, because a faded row (§11) is not a claim that can go stale
/// (the faded-send ruling; §7.2 spells the whole expiry).
pub(super) fn adopt_started(&mut self) {
let Some(mut echo) = self.started.take() else {
return;
};
if let Some(agent) = echo.resolved(&self.derived) {
echo.target = Target::Agent(agent.clone());
self.focus_agent(&echo.ws, &agent);
}
if echo.landed(&self.derived) {
return;
}
self.started = Some(echo);
}
/// Re-record the focused agent's present evidence as seen — the ack held as
/// a **state** rather than spent as a gesture (§6, bl-aa1f). The tick calls
/// it every frame, so evidence that lands while you are already looking at a
/// conversation never raises the flag at the thing you are reading:
/// attention is evidence that arrived while you weren't looking. Free by
/// §4.1's write discipline — an unchanged document hashes to what is already
/// on disk and elides the write outright, so this costs a write only on the
/// frame the evidence actually lands. A focused workspace with no agent
/// selected acks nothing (the general empty path).
pub(super) fn ack_focused(&mut self) {
if let (Some(ws), Some(agent)) = (self.focused_workspace(), self.focus.agent.clone()) {
self.record_seen(&ws, &agent);
}
}
/// The selected §11 Altitude-2 inspector tab (RAM, §5.3) — the digit-key
/// nav target and the shell's tab-strip highlight.
pub fn inspector_tab(&self) -> InspectorTab {
self.focus.tab
}
/// Select an inspector tab (§11 digit keys / tab-strip click). Viewport
/// ephemera: no `ui.json` write, no acknowledgement.
pub fn select_tab(&mut self, tab: InspectorTab) {
self.focus.tab = tab;
}
/// The agent with `id` in `ws`'s snapshot, if both are present.
fn agent_in(&self, ws: &Path, id: &str) -> Option<&crate::git_tree::Agent> {
self.snap
.trees
.get(ws)?
.agents
.iter()
.find(|a| a.agent_id == id)
}
/// Record every present evidence oid for `(ws, agent)` as seen (§6): notify,
/// rest (the branch tip, unless abandoned), budget, conflicted. Recording
/// the *current* oid acknowledges it; a later moved ref re-arms (§4.1). A
/// phantom agent contributes no evidence, so this is a no-op for it.
fn record_seen(&mut self, ws: &Path, agent_id: &str) {
let key = ws_key(ws);
let marks = self.evidence_oids(ws, agent_id);
self.ui.record_seen(&key, agent_id, &marks);
}
/// The present `(kind, oid)` acknowledgement evidence for `(ws, agent)` — an
/// owned list so [`record_seen`](Self::record_seen)'s mutable `ui` write does
/// not overlap the snapshot borrow. Empty when the agent is absent.
/// [`attention::evidence`] is the one definition — read here and by the
/// §8.5 `seen` action — so the window's ack and a headless one write the
/// same bytes, and widening a signal (bl-2194) could not leave either
/// behind.
fn evidence_oids(&self, ws: &Path, agent_id: &str) -> Vec<(SeenKind, String)> {
self.agent_in(ws, agent_id)
.map(attention::evidence)
.unwrap_or_default()
}
/// Jump-to-next-attention (§6): advance focus to the next attention-bearing
/// agent after the current focus (wrapping), and acknowledge it — the strip
/// control that walks the operator through everything that needs them.
///
/// The roster it walks is [`queue::roster`] — **one** build, shared with the
/// §8.5 decision queue, so what this control walks and what a headless seat
/// is handed can never be two orders. It is the sole consumer of §6's rank
/// in the window: the ↑/↓ keys left it with bl-fa82 and now step the focused
/// workspace's visible list rows in paint order (§11's unfold ruling).
pub fn jump_next_attention(&mut self) {
let roster = queue::roster(&self.snap, &self.ui);
// The current position, when both halves are selected — the jump starts
// from the front otherwise. The roster keys workspaces by **path**, so
// the focused name resolves here, at the door that needs it.
let here = self.focused_workspace().map(|p| ws_key(&p));
let agent = self.focus.agent.clone();
let at = here.as_deref().zip(agent.as_deref());
if let Some(k) = attention::next_attention(&roster, at) {
self.focus_agent(&PathBuf::from(k.ws), &k.agent_id);
}
}
/// Toggle the pin on the workspace `name` addresses (§4.1 `pinned`, user
/// order): appended when unpinned, removed when pinned. Durable, converging
/// via `ui.json`. A name the enumeration does not answer pins nothing.
///
/// **The pin toggle is a door** (bl-7407): the tab bar hands back a §3.1
/// name, and `ui.json` keys pins by **path** — durable state whose
/// re-keying is its own migration — so the resolution stands here, once, at
/// the click, rather than on every painted tab.
pub fn toggle_pin(&mut self, name: &str) {
let Some(key) = self.workspace_path(name).map(|p| ws_key(&p)) else {
return;
};
let key = key.as_str();
let mut pins = self.ui.pinned();
match pins.iter().position(|k| k == key) {
Some(i) => {
pins.remove(i);
}
None => pins.push(key.to_string()),
}
self.ui.set_pinned(pins);
}
/// Set a collapse override for a roster section `key` (§4.1 `collapsed`).
pub fn set_collapsed(&mut self, key: &str, collapsed: bool) {
self.ui.set_collapsed(key, collapsed);
}
/// Whether `(kind, ws, agent, oid)` is acknowledged in `ui.json` (§6) — the
/// seen-watermark query, exposed for the convergence proof.
pub fn is_seen(&self, kind: SeenKind, ws: &str, agent: &str, oid: &str) -> bool {
self.ui.is_seen(kind, ws, agent, oid)
}
/// Startup focus (§4.1): the `--workspace` override if given, else the first
/// attention-bearing workspace in derived (path) order, else the first.
///
/// `over` is the roster to derive across rather than `self.snap.workspaces`
/// verbatim, because the §3.6 unmaking re-derives focus *before* the worker
/// has re-enumerated: the dead workspace is still in the snapshot and would
/// win its own attention. One rule, told which roster it applies to — not a
/// deletion special case.
pub(super) fn startup_focus(
&self,
initial: Option<std::path::PathBuf>,
over: &[crate::binding::Workspace],
) -> Focus {
if let Some(ws) = initial {
// `--workspace` may be spelled either way and means one thing: the
// §3.1 name is the leaf, and the leaf of a bare name is itself.
return Focus {
ws: Some(crate::naming::leaf(&ws)),
..Focus::default()
};
}
// Ranked by **path** and answered as a name (§4.1's "derived (path)
// order" is unchanged; only the answer's spelling moved).
let mut ranked: Vec<(String, String)> = over
.iter()
.map(|w| (ws_key(&w.path), crate::naming::leaf(&w.path)))
.collect();
ranked.sort();
let roster: Vec<String> = ranked.into_iter().map(|(_, name)| name).collect();
let mut attention: Vec<String> = Vec::new();
for w in over {
if self.workspace_stats(&w.path).0 > 0 {
attention.push(crate::naming::leaf(&w.path));
}
}
let roster_refs: Vec<&str> = roster.iter().map(String::as_str).collect();
let attention_refs: Vec<&str> = attention.iter().map(String::as_str).collect();
Focus {
ws: crate::ui_state::derive_startup_focus(&roster_refs, &attention_refs),
..Focus::default()
}
}
}