| 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 |