返回 CodeWhale
tideline.rs
根目录 / crates / tui / src / tui / tideline.rs
1 //! Read-only projections for the Tideline terminal workbench contract.
2 //!
3 //! [`App`] remains the sole owner of runtime state. This module neither
4 //! replaces it nor introduces another settings store, event loop, or engine;
5 //! it gives render and input code typed snapshots of facts that existing
6 //! owners have already resolved.
7
8 use ratatui::layout::{Position, Rect};
9
10 use crate::tui::app::App;
11
12 /// A bounded view of the context window currently owned by the active route.
13 ///
14 /// Percent is stored in basis points (`10_000 == 100%`) so snapshots remain
15 /// equality-testable without making renderers compare floating-point values.
16 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 pub struct ContextBudgetSnapshot {
18 pub used_tokens: u32,
19 pub max_tokens: u32,
20 pub percent_basis_points: u16,
21 }
22
23 impl ContextBudgetSnapshot {
24 /// Project the existing context estimator without becoming a second
25 /// context-budget owner.
26 #[must_use]
27 pub(crate) fn from_app(app: &App) -> Option<Self> {
28 let (used, max_tokens, _) = crate::tui::ui::context_usage_snapshot(app)?;
29 let used_tokens = u32::try_from(used.max(0))
30 .unwrap_or(u32::MAX)
31 .min(max_tokens);
32 let percent_basis_points = if max_tokens == 0 {
33 0
34 } else {
35 let numerator = u64::from(used_tokens).saturating_mul(10_000);
36 let rounded = numerator
37 .saturating_add(u64::from(max_tokens) / 2)
38 .checked_div(u64::from(max_tokens))
39 .unwrap_or(0)
40 .min(10_000);
41 u16::try_from(rounded).unwrap_or(10_000)
42 };
43
44 Some(Self {
45 used_tokens,
46 max_tokens,
47 percent_basis_points,
48 })
49 }
50 }
51
52 /// The owner whose value currently wins for a setting fact.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 #[allow(
55 dead_code,
56 reason = "later settings surfaces consume the non-session authority variants"
57 )]
58 pub enum SettingAuthority {
59 Session,
60 UserSettings,
61 WorkspaceConfiguration,
62 ManagedPolicy,
63 /// An environment variable or session (SSH) forces the effective value.
64 Environment,
65 /// The terminal program forces the effective value.
66 Terminal,
67 }
68
69 /// When an edit to a setting becomes observable.
70 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71 #[allow(
72 dead_code,
73 reason = "later settings editors consume the non-current apply variants"
74 )]
75 pub enum SettingApplySemantics {
76 EffectiveNow,
77 Immediate,
78 NextSession,
79 RestartRequired,
80 ReadOnly,
81 /// Persisted and the live owner updated now, but a running consumer keeps
82 /// the old value until it is explicitly reloaded (MCP servers after an
83 /// `mcp_config_path` change: `/mcp reload`).
84 ReloadRequired,
85 /// The UI applies the edit now while engine tools only read it at startup
86 /// (`workspace_follow_symlinks`).
87 UiNowEngineRestart,
88 }
89
90 /// One setting without collapsing live, resolved, startup, and persisted
91 /// values into an ambiguous `Session`/`Saved` label.
92 #[derive(Debug, Clone, PartialEq, Eq)]
93 #[allow(
94 dead_code,
95 reason = "the settings view consumes this projection in the next Tideline slice"
96 )]
97 pub struct SettingFact<T> {
98 /// Value currently held by the live owner before further resolution.
99 pub current: Option<T>,
100 /// Value actually in force after route, policy, or session overrides.
101 pub effective: Option<T>,
102 /// Value a fresh session is expected to start with, when observed.
103 pub startup: Option<T>,
104 /// Exact persisted value last read from its owning store, when observed.
105 pub saved: Option<T>,
106 pub authority: SettingAuthority,
107 pub apply: SettingApplySemantics,
108 }
109
110 #[allow(
111 dead_code,
112 reason = "the settings view consumes this projection in the next Tideline slice"
113 )]
114 impl<T: Clone> SettingFact<T> {
115 /// A fact already owned by the active session.
116 #[must_use]
117 pub fn active_session(value: T) -> Self {
118 Self {
119 current: Some(value.clone()),
120 effective: Some(value),
121 startup: None,
122 saved: None,
123 authority: SettingAuthority::Session,
124 apply: SettingApplySemantics::EffectiveNow,
125 }
126 }
127 }
128
129 /// The narrow, read-only workbench projection available in this slice.
130 ///
131 /// `App` intentionally does not retain a resident [`crate::settings::Settings`]
132 /// value. Consequently this projection never reloads disk or guesses startup
133 /// defaults: those lanes remain `None` until the settings owner supplies them.
134 #[derive(Debug, Clone, PartialEq, Eq)]
135 #[allow(
136 dead_code,
137 reason = "the composed workbench consumes this projection in the next Tideline slice"
138 )]
139 pub struct UiSnapshot {
140 pub context_budget: Option<ContextBudgetSnapshot>,
141 pub provider: SettingFact<String>,
142 pub model: SettingFact<String>,
143 }
144
145 #[allow(
146 dead_code,
147 reason = "the composed workbench consumes this projection in the next Tideline slice"
148 )]
149 impl UiSnapshot {
150 #[must_use]
151 pub(crate) fn from_app(app: &App) -> Self {
152 let (provider, model) = app.effective_route_identity_display();
153 Self {
154 context_budget: ContextBudgetSnapshot::from_app(app),
155 provider: SettingFact::active_session(provider),
156 model: SettingFact::active_session(model),
157 }
158 }
159 }
160
161 /// Stable identifier from the Tideline wiring manifest.
162 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163 pub struct InteractionTargetId(&'static str);
164
165 impl InteractionTargetId {
166 pub const HEADER_CONTEXT: Self = Self("header.context");
167 /// The rendered route/model segment. This is intentionally an affordance
168 /// id only: the provider picker remains the owner of route catalog and
169 /// readiness facts.
170 pub const HEADER_ROUTE: Self = Self("header.route");
171 /// The model name and effort tier inside the info line's route segment.
172 pub const HEADER_MODEL: Self = Self("header.model");
173 /// A live count in the posture bar; opens the dock view it counts.
174 pub const FOOTER_COUNT: Self = Self("footer.count");
175 pub const DOCK_TAB_AGENTS: Self = Self("dock.tab.agents");
176 pub const DOCK_TAB_TASKS: Self = Self("dock.tab.tasks");
177 pub const DOCK_TAB_BACKGROUND: Self = Self("dock.tab.background");
178 pub const DOCK_TAB_FILES: Self = Self("dock.tab.files");
179 pub const DOCK_TAB_NOTEPAD: Self = Self("dock.tab.notepad");
180 pub const DOCK_TAB_CONTEXT: Self = Self("dock.tab.context");
181 pub const DOCK_TAB_GIT: Self = Self("dock.tab.git");
182 pub const DOCK_TAB_PRICE: Self = Self("dock.tab.price");
183 pub const DOCK_CLOSE: Self = Self("dock.close");
184 }
185
186 /// Typed destination shared by keyboard and mouse input routes.
187 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
188 pub enum InteractionAction {
189 InspectContext,
190 /// Open the existing provider/route picker without making this chrome
191 /// target another source of catalog or runtime authority.
192 OpenProviderPicker,
193 /// Open the existing `/model` picker. Same discipline as the provider
194 /// entry: an entry point, never a second catalog.
195 OpenModelPicker,
196 /// Open the existing automations manager.
197 OpenAutomations,
198 ShowDockPanel(crate::tui::work_surface::RailPanel),
199 DismissDock,
200 }
201
202 /// Focus metadata for a selectable target.
203 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
204 #[allow(
205 dead_code,
206 reason = "ordered focus traversal lands with the later multi-target surfaces"
207 )]
208 pub enum InteractionFocus {
209 /// The target has a direct keyboard shortcut but is not in traversal yet.
210 Direct,
211 /// The target participates in ordered focus traversal.
212 Traversable { order: u16, focused: bool },
213 }
214
215 /// Typed, non-prose evidence made available to an inspector.
216 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
217 pub enum InspectDetail {
218 ContextBudget(ContextBudgetSnapshot),
219 /// The topbar exposes a route entry point, not a copied route snapshot.
220 /// `ProviderPickerView` remains the authoritative presentation owner.
221 Route,
222 }
223
224 /// A selectable region painted in the current frame.
225 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
226 pub struct InteractionTarget {
227 pub id: InteractionTargetId,
228 pub area: Rect,
229 pub focus: InteractionFocus,
230 pub keyboard_action: Option<InteractionAction>,
231 pub mouse_action: Option<InteractionAction>,
232 pub inspect_detail: InspectDetail,
233 }
234
235 /// Frame-scoped interaction geometry.
236 ///
237 /// Targets are cleared before every render. Hit testing runs newest-first so a
238 /// later modal or overlay can safely own cells also covered by a lower layer.
239 #[derive(Debug, Default)]
240 pub struct InteractionRegistry {
241 targets: Vec<InteractionTarget>,
242 }
243
244 impl InteractionRegistry {
245 pub fn clear(&mut self) {
246 self.targets.clear();
247 }
248
249 pub fn register(&mut self, target: InteractionTarget) {
250 if target.area.width > 0 && target.area.height > 0 {
251 self.targets.push(target);
252 }
253 }
254
255 #[must_use]
256 pub fn target_at(&self, column: u16, row: u16) -> Option<&InteractionTarget> {
257 let position = Position::new(column, row);
258 self.targets
259 .iter()
260 .rev()
261 .find(|target| target.area.contains(position))
262 }
263
264 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &InteractionTarget> {
265 self.targets.iter()
266 }
267 }
268
269 #[cfg(test)]
270 mod tests {
271 use super::{
272 ContextBudgetSnapshot, InspectDetail, InteractionAction, InteractionFocus,
273 InteractionRegistry, InteractionTarget, InteractionTargetId, SettingApplySemantics,
274 SettingAuthority, SettingFact, UiSnapshot,
275 };
276 use crate::config::ApiProvider;
277 use ratatui::layout::Rect;
278
279 fn target(area: Rect, used_tokens: u32) -> InteractionTarget {
280 InteractionTarget {
281 id: InteractionTargetId::HEADER_CONTEXT,
282 area,
283 focus: InteractionFocus::Direct,
284 keyboard_action: Some(InteractionAction::InspectContext),
285 mouse_action: Some(InteractionAction::InspectContext),
286 inspect_detail: InspectDetail::ContextBudget(ContextBudgetSnapshot {
287 used_tokens,
288 max_tokens: 10_000,
289 percent_basis_points: 3_000,
290 }),
291 }
292 }
293
294 #[test]
295 fn topbar_route_target_is_typed_without_copying_route_facts() {
296 let target = InteractionTarget {
297 id: InteractionTargetId::HEADER_ROUTE,
298 area: Rect::new(20, 0, 24, 1),
299 focus: InteractionFocus::Direct,
300 keyboard_action: Some(InteractionAction::OpenProviderPicker),
301 mouse_action: Some(InteractionAction::OpenProviderPicker),
302 inspect_detail: InspectDetail::Route,
303 };
304
305 assert_eq!(target.id, InteractionTargetId::HEADER_ROUTE);
306 assert_eq!(
307 target.keyboard_action,
308 Some(InteractionAction::OpenProviderPicker)
309 );
310 assert_eq!(target.mouse_action, target.keyboard_action);
311 assert_eq!(target.inspect_detail, InspectDetail::Route);
312 }
313
314 #[test]
315 fn ui_snapshot_uses_active_route_without_claiming_saved_defaults() {
316 let mut app =
317 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
318 app.pending_turn_route = Some((ApiProvider::Zai, "GLM-5.3".to_string(), false));
319
320 let snapshot = UiSnapshot::from_app(&app);
321
322 assert_eq!(
323 snapshot.provider.current.as_deref(),
324 Some(ApiProvider::Zai.display_name())
325 );
326 assert_eq!(snapshot.provider.current, snapshot.provider.effective);
327 assert_eq!(snapshot.model.current.as_deref(), Some("GLM-5.3"));
328 assert_eq!(snapshot.model.current, snapshot.model.effective);
329 assert!(snapshot.provider.startup.is_none());
330 assert!(snapshot.provider.saved.is_none());
331 assert_eq!(snapshot.provider.authority, SettingAuthority::Session);
332 assert_eq!(snapshot.provider.apply, SettingApplySemantics::EffectiveNow);
333 }
334
335 #[test]
336 fn context_budget_projection_reuses_and_bounds_the_existing_estimate() {
337 let app =
338 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
339 let (used, max_tokens, _) =
340 crate::tui::ui::context_usage_snapshot(&app).expect("existing context estimate");
341 let snapshot = ContextBudgetSnapshot::from_app(&app).expect("Tideline projection");
342
343 assert_eq!(snapshot.used_tokens, u32::try_from(used).unwrap());
344 assert_eq!(snapshot.max_tokens, max_tokens);
345 assert!(snapshot.used_tokens <= snapshot.max_tokens);
346 assert!(snapshot.percent_basis_points <= 10_000);
347 }
348
349 #[test]
350 fn setting_fact_keeps_live_startup_and_saved_lanes_distinct() {
351 let fact = SettingFact {
352 current: Some("session"),
353 effective: Some("managed"),
354 startup: Some("next"),
355 saved: Some("disk"),
356 authority: SettingAuthority::ManagedPolicy,
357 apply: SettingApplySemantics::NextSession,
358 };
359
360 assert_eq!(fact.current, Some("session"));
361 assert_eq!(fact.effective, Some("managed"));
362 assert_eq!(fact.startup, Some("next"));
363 assert_eq!(fact.saved, Some("disk"));
364 }
365
366 #[test]
367 fn apply_semantics_distinguish_reload_and_partial_restart_from_full_restart() {
368 let reload = SettingFact {
369 current: Some("live"),
370 effective: None,
371 startup: None,
372 saved: Some("disk"),
373 authority: SettingAuthority::UserSettings,
374 apply: SettingApplySemantics::ReloadRequired,
375 };
376 let partial = SettingFact {
377 apply: SettingApplySemantics::UiNowEngineRestart,
378 ..reload.clone()
379 };
380 assert_ne!(reload.apply, SettingApplySemantics::RestartRequired);
381 assert_ne!(partial.apply, SettingApplySemantics::RestartRequired);
382 assert_ne!(reload.apply, partial.apply);
383 assert_ne!(reload.apply, SettingApplySemantics::Immediate);
384 }
385
386 #[test]
387 fn registry_ignores_empty_geometry_and_prefers_the_topmost_target() {
388 let mut registry = InteractionRegistry::default();
389
390 registry.register(target(Rect::new(2, 2, 6, 3), 3_000));
391 registry.register(target(Rect::new(4, 3, 6, 3), 4_000));
392 registry.register(target(Rect::new(0, 0, 0, 1), 5_000));
393
394 assert_eq!(registry.iter().count(), 2);
395 assert_eq!(
396 registry.target_at(5, 3).map(|target| target.area),
397 Some(Rect::new(4, 3, 6, 3))
398 );
399 assert_eq!(
400 registry.target_at(2, 2).map(|target| target.area),
401 Some(Rect::new(2, 2, 6, 3))
402 );
403 assert!(registry.target_at(20, 20).is_none());
404
405 registry.clear();
406 assert_eq!(registry.iter().count(), 0);
407 }
408 }
409
409 lines RUST