|
| 1 | +//! Transcript operations: add/replace/push, notifications, scroll. |
| 2 | +
|
| 3 | +use claurst_core::types::{Message, Role}; |
| 4 | +use crate::notifications::NotificationKind; |
| 5 | +use super::App; |
| 6 | +use super::types::{SystemAnnotation, SystemMessageStyle}; |
| 7 | + |
| 8 | +impl App { |
| 9 | + /// Add a message directly (e.g. from a non-streaming source). |
| 10 | + pub fn add_message(&mut self, role: Role, text: String) { |
| 11 | + let msg = match role { |
| 12 | + Role::User => Message::user(text), |
| 13 | + Role::Assistant => Message::assistant(text), |
| 14 | + }; |
| 15 | + if role == Role::User { |
| 16 | + self.begin_user_turn_snapshot(); |
| 17 | + } |
| 18 | + self.messages.push(msg); |
| 19 | + self.invalidate_transcript(); |
| 20 | + self.on_new_message(); |
| 21 | + } |
| 22 | + |
| 23 | + pub fn replace_messages(&mut self, messages: Vec<Message>) { |
| 24 | + self.messages = messages; |
| 25 | + self.sync_turn_metadata_to_messages(); |
| 26 | + self.invalidate_transcript(); |
| 27 | + } |
| 28 | + |
| 29 | + pub fn push_message(&mut self, message: Message) { |
| 30 | + if message.role == Role::User { |
| 31 | + self.begin_user_turn_snapshot(); |
| 32 | + } |
| 33 | + self.messages.push(message); |
| 34 | + self.sync_turn_metadata_to_messages(); |
| 35 | + self.invalidate_transcript(); |
| 36 | + self.on_new_message(); |
| 37 | + } |
| 38 | + |
| 39 | + /// Push a synthetic system annotation into the conversation pane. |
| 40 | + /// It will appear after the current last message. |
| 41 | + /// Push a notification and, for Error-kind notifications, reset the error |
| 42 | + /// modal scroll offset so a newly arrived error is always shown from the top. |
| 43 | + pub fn push_notification(&mut self, kind: NotificationKind, msg: String, duration_secs: Option<u64>) { |
| 44 | + if kind == NotificationKind::Error { |
| 45 | + self.error_modal_scroll_offset = 0; |
| 46 | + } |
| 47 | + self.notifications.push(kind, msg, duration_secs); |
| 48 | + } |
| 49 | + |
| 50 | + pub fn push_system_message(&mut self, text: String, style: SystemMessageStyle) { |
| 51 | + self.system_annotations.push(SystemAnnotation { |
| 52 | + after_index: self.messages.len(), |
| 53 | + text, |
| 54 | + style, |
| 55 | + }); |
| 56 | + self.invalidate_transcript(); |
| 57 | + } |
| 58 | + |
| 59 | + /// Called whenever a new message is appended to `messages`. |
| 60 | + /// Manages the auto-scroll / new-message-counter state. |
| 61 | + pub(super) fn on_new_message(&mut self) { |
| 62 | + if self.auto_scroll { |
| 63 | + // Auto-scroll: keep offset at 0 so render shows the bottom. |
| 64 | + self.scroll_offset = 0; |
| 65 | + } else { |
| 66 | + self.new_messages_while_scrolled = |
| 67 | + self.new_messages_while_scrolled.saturating_add(1); |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + pub fn invalidate_transcript(&self) { |
| 72 | + self.transcript_version |
| 73 | + .set(self.transcript_version.get().wrapping_add(1)); |
| 74 | + } |
| 75 | + |
| 76 | + /// Check current token usage and push token warning notifications as |
| 77 | + /// appropriate. Call this after updating `token_count`. |
| 78 | + pub fn check_token_warnings(&mut self) { |
| 79 | + let window = |
| 80 | + claurst_query::context_window_for_model(&self.model_name) as u32; |
| 81 | + if window == 0 { |
| 82 | + return; |
| 83 | + } |
| 84 | + let pct = (self.token_count as f64 / window as f64 * 100.0) as u8; |
| 85 | + |
| 86 | + // Only escalate — never repeat a threshold already shown. |
| 87 | + if pct >= 100 && self.token_warning_threshold_shown < 100 { |
| 88 | + self.token_warning_threshold_shown = 100; |
| 89 | + self.push_notification( |
| 90 | + NotificationKind::Error, |
| 91 | + "Context window full. Running auto-compact\u{2026}".to_string(), |
| 92 | + None, |
| 93 | + ); |
| 94 | + } else if pct >= 95 && self.token_warning_threshold_shown < 95 { |
| 95 | + self.token_warning_threshold_shown = 95; |
| 96 | + self.push_notification( |
| 97 | + NotificationKind::Error, |
| 98 | + "Context window 95% full! Run /compact now.".to_string(), |
| 99 | + None, // persistent until dismissed |
| 100 | + ); |
| 101 | + } else if pct >= 80 && self.token_warning_threshold_shown < 80 { |
| 102 | + self.token_warning_threshold_shown = 80; |
| 103 | + self.push_notification( |
| 104 | + NotificationKind::Warning, |
| 105 | + "Context window 80% full. Consider /compact.".to_string(), |
| 106 | + Some(30), |
| 107 | + ); |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + /// Take the current input buffer, push it to history, and return it. |
| 112 | + pub fn take_input(&mut self) -> String { |
| 113 | + let input = self.prompt_input.take(); |
| 114 | + if !input.is_empty() { |
| 115 | + self.prompt_input.history.push(input.clone()); |
| 116 | + self.prompt_input.history_pos = None; |
| 117 | + self.prompt_input.history_draft.clear(); |
| 118 | + self.input_history = self.prompt_input.history.clone(); |
| 119 | + self.history_index = self.prompt_input.history_pos; |
| 120 | + } |
| 121 | + self.refresh_prompt_input(); |
| 122 | + input |
| 123 | + } |
| 124 | + |
| 125 | + /// Scroll the transcript up by `amount` lines and disable auto-follow. |
| 126 | + /// |
| 127 | + /// `scroll_offset` counts lines above the bottom (0 = pinned to the newest |
| 128 | + /// content). It is clamped to `last_max_scroll` — the maximum meaningful |
| 129 | + /// offset from the last render — so scrolling up past the top of the |
| 130 | + /// transcript can't inflate it unboundedly. Without the clamp, an over-scroll |
| 131 | + /// would leave `scroll_offset` far above `max_scroll`, and the user would |
| 132 | + /// have to press Down that many times before the view moved (#223). |
| 133 | + pub(super) fn scroll_up_by(&mut self, amount: usize) { |
| 134 | + self.scroll_offset = self |
| 135 | + .scroll_offset |
| 136 | + .saturating_add(amount) |
| 137 | + .min(self.last_max_scroll.get()); |
| 138 | + self.auto_scroll = false; |
| 139 | + } |
| 140 | + |
| 141 | + /// Compute the number of lines to scroll per wheel/trackpad event. |
| 142 | + /// Implements a simple acceleration model: rapid events (< 40 ms apart) are |
| 143 | + /// treated as trackpad bursts and accelerate up to 2×; slower events (mouse |
| 144 | + /// wheel) stay at the base 3-line step. |
| 145 | + pub(super) fn scroll_step(&mut self) -> usize { |
| 146 | + let now = std::time::Instant::now(); |
| 147 | + let elapsed_ms = self.scroll_last_time |
| 148 | + .map(|t| now.duration_since(t).as_millis()) |
| 149 | + .unwrap_or(u128::MAX); |
| 150 | + self.scroll_last_time = Some(now); |
| 151 | + if elapsed_ms < 40 { |
| 152 | + // Trackpad burst — gradually accelerate |
| 153 | + self.scroll_accel = (self.scroll_accel + 0.4).min(6.0); |
| 154 | + } else { |
| 155 | + // Mouse click or first event — reset to base |
| 156 | + self.scroll_accel = 3.0; |
| 157 | + } |
| 158 | + self.scroll_accel.round() as usize |
| 159 | + } |
| 160 | + |
| 161 | +} |
0 commit comments