| @@ -634,9 +634,13 @@ pub struct Question { |
| 634 | 634 | /// An answer as the agent wrote it, and the Markdown view showing it. While |
| 635 | 635 | /// streaming, the view may lag behind the text to show only finished |
| 636 | 636 | /// paragraphs. |
| 637 | + /// |
| 638 | + /// The view is made the first time the answer is drawn ([`Self::view`]): |
| 639 | + /// a parsed Markdown view costs several times its text, and most answers of |
| 640 | + /// restored threads are never scrolled to. |
| 637 | 641 | pub struct AssistantText { |
| 638 | 642 | pub text: String, |
| 639 | | - pub view: Entity<TextViewState>, |
| 643 | + view: Option<Entity<TextViewState>>, |
| 640 | 644 | /// How much of `text` the view shows. |
| 641 | 645 | shown: usize, |
| 642 | 646 | } |
| @@ -957,7 +961,10 @@ pub struct NewSession { |
| 957 | 961 | impl EventEmitter<SessionEvent> for Session {} |
| 958 | 962 | |
| 959 | 963 | impl Session { |
| 960 | | - pub fn new(new: NewSession, cx: &mut Context<Self>) -> Self { |
| 964 | + /// A new thread. Its agent isn't started yet: [`Session::warm_up`] |
| 965 | + /// starts it once the user begins typing, and the first message starts |
| 966 | + /// it otherwise, so a draft nobody writes in costs no agent process. |
| 967 | + pub fn new(new: NewSession, _cx: &mut Context<Self>) -> Self { |
| 961 | 968 | let now = SystemTime::now(); |
| 962 | 969 | let id = format!( |
| 963 | 970 | "{:x}-{:x}-{}", |
| @@ -965,16 +972,26 @@ impl Session { |
| 965 | 972 | std::process::id(), |
| 966 | 973 | NEXT_SESSION.load(Ordering::Relaxed) |
| 967 | 974 | ); |
| 968 | | - let start = new.worktree.is_none(); |
| 969 | 975 | let mut session = Self::empty(id, new, now); |
| 970 | | - if start { |
| 971 | | - session.start(None, cx); |
| 972 | | - } else { |
| 973 | | - session.status = SessionStatus::Idle; |
| 974 | | - } |
| 976 | + session.status = SessionStatus::Idle; |
| 975 | 977 | session |
| 976 | 978 | } |
| 977 | 979 | |
| 980 | + /// Starts the agent of a thread nothing was sent in yet, ahead of its |
| 981 | + /// first message, so it's ready by the time the message is. |
| 982 | + pub fn warm_up(&mut self, cx: &mut Context<Self>) { |
| 983 | + if self.process.is_some() |
| 984 | + || self.remote.is_some() |
| 985 | + || self.pending_worktree.is_some() |
| 986 | + || self.preparing |
| 987 | + || !self.items.is_empty() |
| 988 | + || self.status == SessionStatus::Exited |
| 989 | + { |
| 990 | + return; |
| 991 | + } |
| 992 | + self.start(None, cx); |
| 993 | + } |
| 994 | + |
| 978 | 995 | /// A subagent's thread: it runs a task `parent` delegated, in the |
| 979 | 996 | /// parent's working folder, and starts on the task right away. |
| 980 | 997 | pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self { |
| @@ -1967,6 +1984,39 @@ impl Session { |
| 1967 | 1984 | true |
| 1968 | 1985 | } |
| 1969 | 1986 | |
| 1987 | + /// Whether the agent process has nothing to do and nothing waits on it: |
| 1988 | + /// stopping it now loses nothing, as the next message resumes the |
| 1989 | + /// conversation in a new one. |
| 1990 | + fn agent_idle(&self) -> bool { |
| 1991 | + self.process.is_some() |
| 1992 | + && self.remote.is_none() |
| 1993 | + && !self.is_working() |
| 1994 | + && !self.preparing |
| 1995 | + && !self.needs_attention() |
| 1996 | + && self.background_tasks.is_empty() |
| 1997 | + && self.queue.is_empty() |
| 1998 | + && self.pending_controls.is_empty() |
| 1999 | + && self.resume_at.is_none() |
| 2000 | + && self.jcode.agent_poll.is_none() |
| 2001 | + && !self.swarm_busy() |
| 2002 | + // A thread nothing was sent in has no conversation to resume yet, |
| 2003 | + // and one whose agent never said its session id can't resume it. |
| 2004 | + && !self.items.is_empty() |
| 2005 | + && self.resume_id.is_some() |
| 2006 | + } |
| 2007 | + |
| 2008 | + /// Stops the agent process of a thread that sat idle for `after`, to |
| 2009 | + /// give its memory back. The thread stays as it is; its next message |
| 2010 | + /// starts the agent again, resuming the conversation. Returns whether it |
| 2011 | + /// stopped one. |
| 2012 | + pub fn release_idle_agent(&mut self, after: Duration, cx: &mut Context<Self>) -> bool { |
| 2013 | + let quiet = SystemTime::now().duration_since(self.updated).unwrap_or_default(); |
| 2014 | + if quiet < after || !self.agent_idle() { |
| 2015 | + return false; |
| 2016 | + } |
| 2017 | + self.restart_agent(cx) |
| 2018 | + } |
| 2019 | + |
| 1970 | 2020 | /// Moves a fresh thread into another thread's worktree, as "New thread on |
| 1971 | 2021 | /// {branch}" does for a worktree thread, restarting its agent there. |
| 1972 | 2022 | pub fn adopt_worktree(&mut self, worktree: Worktree, cwd: PathBuf, cx: &mut Context<Self>) { |
| @@ -2056,6 +2106,9 @@ impl Session { |
| 2056 | 2106 | } |
| 2057 | 2107 | self.settled = true; |
| 2058 | 2108 | self.preparing = false; |
| 2109 | + // A settled thread is out of sight; its answers' views come back |
| 2110 | + // from the text if it's opened again. |
| 2111 | + self.release_views(); |
| 2059 | 2112 | cx.notify(); |
| 2060 | 2113 | } |
| 2061 | 2114 | |
| @@ -2283,6 +2336,25 @@ impl Session { |
| 2283 | 2336 | Some(FinishedTurn { first, answer, end, duration_ms, cost, at, has_work }) |
| 2284 | 2337 | } |
| 2285 | 2338 | |
| 2339 | + /// The Markdown view of the answer at `ix`, made the first time it's drawn. |
| 2340 | + pub fn answer_view(&mut self, ix: usize, cx: &mut App) -> Option<Entity<TextViewState>> { |
| 2341 | + match self.items.get_mut(ix)? { |
| 2342 | + ChatItem::Assistant(answer) => Some(answer.view(cx)), |
| 2343 | + _ => None, |
| 2344 | + } |
| 2345 | + } |
| 2346 | + |
| 2347 | + /// Drops the transcript's Markdown views, which are rebuilt from the text |
| 2348 | + /// as the answers are drawn again. Their parsed documents take several |
| 2349 | + /// times the memory of the text. |
| 2350 | + pub fn release_views(&mut self) { |
| 2351 | + for item in &mut self.items { |
| 2352 | + if let ChatItem::Assistant(answer) = item { |
| 2353 | + answer.release_view(); |
| 2354 | + } |
| 2355 | + } |
| 2356 | + } |
| 2357 | + |
| 2286 | 2358 | pub fn toggle_tool(&mut self, ix: usize, cx: &mut Context<Self>) { |
| 2287 | 2359 | if let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) { |
| 2288 | 2360 | tool.expanded = !tool.expanded; |
| @@ -2784,7 +2856,7 @@ impl Session { |
| 2784 | 2856 | let item_ix = match block.get("type").and_then(Value::as_str) { |
| 2785 | 2857 | Some("text") => { |
| 2786 | 2858 | let text = block.get("text").and_then(Value::as_str).unwrap_or_default(); |
| 2787 | | - Some(self.push_assistant_text(text, cx)) |
| 2859 | + Some(self.push_assistant_text(text)) |
| 2788 | 2860 | } |
| 2789 | 2861 | Some("tool_use") => Some(self.push_tool( |
| 2790 | 2862 | block.get("id").and_then(Value::as_str).unwrap_or_default(), |
| @@ -2862,7 +2934,7 @@ impl Session { |
| 2862 | 2934 | |
| 2863 | 2935 | /// Complete assistant messages. Content we already streamed is skipped; |
| 2864 | 2936 | /// they only carry the authoritative tool inputs in that case. |
| 2865 | | - fn handle_assistant(&mut self, message: &Value, cx: &mut Context<Self>) { |
| 2937 | + fn handle_assistant(&mut self, message: &Value, _cx: &mut Context<Self>) { |
| 2866 | 2938 | let already_streamed = message |
| 2867 | 2939 | .get("id") |
| 2868 | 2940 | .and_then(Value::as_str) |
| @@ -2874,7 +2946,7 @@ impl Session { |
| 2874 | 2946 | Some("text") if !already_streamed => { |
| 2875 | 2947 | let text = block.get("text").and_then(Value::as_str).unwrap_or_default(); |
| 2876 | 2948 | if !text.trim().is_empty() { |
| 2877 | | - self.push_assistant_text(text, cx); |
| 2949 | + self.push_assistant_text(text); |
| 2878 | 2950 | } |
| 2879 | 2951 | } |
| 2880 | 2952 | Some("tool_use") => { |
| @@ -3288,7 +3360,7 @@ impl Session { |
| 3288 | 3360 | } |
| 3289 | 3361 | match event.get("type").and_then(Value::as_str) { |
| 3290 | 3362 | Some("text_start") => { |
| 3291 | | - let ix = self.push_assistant_text("", cx); |
| 3363 | + let ix = self.push_assistant_text(""); |
| 3292 | 3364 | self.map_block(index, ix); |
| 3293 | 3365 | } |
| 3294 | 3366 | Some("text_delta") => { |
| @@ -3296,7 +3368,7 @@ impl Session { |
| 3296 | 3368 | let ix = match self.streamed_item(index) { |
| 3297 | 3369 | Some(ix) => ix, |
| 3298 | 3370 | None => { |
| 3299 | | - let ix = self.push_assistant_text("", cx); |
| 3371 | + let ix = self.push_assistant_text(""); |
| 3300 | 3372 | self.map_block(index, ix); |
| 3301 | 3373 | ix |
| 3302 | 3374 | } |
| @@ -3483,10 +3555,7 @@ impl Session { |
| 3483 | 3555 | "text_replace" => { |
| 3484 | 3556 | let ix = self.jcode_text_item(text("message_id"), cx); |
| 3485 | 3557 | if let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) { |
| 3486 | | - answer.text = text("text").to_string(); |
| 3487 | | - answer.shown = answer.text.len(); |
| 3488 | | - let replacement = answer.text.clone(); |
| 3489 | | - answer.view.update(cx, |view, cx| view.set_text(&replacement, cx)); |
| 3558 | + answer.replace(text("text").to_string(), cx); |
| 3490 | 3559 | } |
| 3491 | 3560 | } |
| 3492 | 3561 | "text_done" => { |
| @@ -3674,11 +3743,11 @@ impl Session { |
| 3674 | 3743 | } |
| 3675 | 3744 | |
| 3676 | 3745 | /// The chat item a streamed jcode message renders into, made on its first delta. |
| 3677 | | - fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize { |
| 3746 | + fn jcode_text_item(&mut self, message_id: &str, _cx: &mut Context<Self>) -> usize { |
| 3678 | 3747 | if let Some(&ix) = self.jcode.texts.get(message_id) { |
| 3679 | 3748 | return ix; |
| 3680 | 3749 | } |
| 3681 | | - let ix = self.push_assistant_text("", cx); |
| 3750 | + let ix = self.push_assistant_text(""); |
| 3682 | 3751 | self.jcode.texts.insert(message_id.to_string(), ix); |
| 3683 | 3752 | ix |
| 3684 | 3753 | } |
| @@ -3821,9 +3890,8 @@ impl Session { |
| 3821 | 3890 | |
| 3822 | 3891 | // ---- helpers ----------------------------------------------------- |
| 3823 | 3892 | |
| 3824 | | - fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize { |
| 3825 | | - let view = cx.new(|cx| TextViewState::markdown(text, cx).selectable(true)); |
| 3826 | | - self.items.push(ChatItem::Assistant(AssistantText { text: text.to_string(), view, shown: text.len() })); |
| 3893 | + fn push_assistant_text(&mut self, text: &str) -> usize { |
| 3894 | + self.items.push(ChatItem::Assistant(AssistantText::new(text.to_string(), text.len()))); |
| 3827 | 3895 | self.items.len() - 1 |
| 3828 | 3896 | } |
| 3829 | 3897 | |
| @@ -4191,7 +4259,7 @@ impl Session { |
| 4191 | 4259 | |
| 4192 | 4260 | /// Brings a saved thread back. Its agent starts again, resuming the |
| 4193 | 4261 | /// conversation, with the next message. |
| 4194 | | - pub fn restore(saved: SavedThread, cx: &mut Context<Self>) -> Self { |
| 4262 | + pub fn restore(saved: SavedThread, _cx: &mut Context<Self>) -> Self { |
| 4195 | 4263 | let new = NewSession { |
| 4196 | 4264 | instance: saved.instance, |
| 4197 | 4265 | provider: saved.provider, |
| @@ -4232,7 +4300,7 @@ impl Session { |
| 4232 | 4300 | session.turn_changes = saved.turn_changes.into_iter().collect(); |
| 4233 | 4301 | session.delegation = saved.delegation; |
| 4234 | 4302 | for item in saved.items { |
| 4235 | | - let item = session.item_from_saved(item, false, cx); |
| 4303 | + let item = session.item_from_saved(item, false); |
| 4236 | 4304 | session.items.push(item); |
| 4237 | 4305 | } |
| 4238 | 4306 | if session.interrupted { |
| @@ -4244,7 +4312,7 @@ impl Session { |
| 4244 | 4312 | /// Turns a saved item back into a chat item. `live` keeps pending |
| 4245 | 4313 | /// prompts and questions pending (a host's transcript); otherwise they |
| 4246 | 4314 | /// count as over (a thread restored from disk). |
| 4247 | | - fn item_from_saved(&mut self, item: SavedItem, live: bool, cx: &mut Context<Self>) -> ChatItem { |
| 4315 | + fn item_from_saved(&mut self, item: SavedItem, live: bool) -> ChatItem { |
| 4248 | 4316 | match item { |
| 4249 | 4317 | SavedItem::User { text, attachments, at } => ChatItem::User { |
| 4250 | 4318 | text: text.into(), |
| @@ -4252,9 +4320,8 @@ impl Session { |
| 4252 | 4320 | at: from_millis(at), |
| 4253 | 4321 | }, |
| 4254 | 4322 | SavedItem::Assistant { text } => { |
| 4255 | | - let view = cx.new(|cx| TextViewState::markdown(&text, cx).selectable(true)); |
| 4256 | 4323 | let shown = text.len(); |
| 4257 | | - ChatItem::Assistant(AssistantText { text, view, shown }) |
| 4324 | + ChatItem::Assistant(AssistantText::new(text, shown)) |
| 4258 | 4325 | } |
| 4259 | 4326 | SavedItem::Tool { |
| 4260 | 4327 | id, |
| @@ -4626,7 +4693,7 @@ impl Session { |
| 4626 | 4693 | answer.show(end, cx); |
| 4627 | 4694 | continue; |
| 4628 | 4695 | } |
| 4629 | | - let mut item = self.item_from_saved(saved, true, cx); |
| 4696 | + let mut item = self.item_from_saved(saved, true); |
| 4630 | 4697 | // Keep tool cards open across updates. |
| 4631 | 4698 | if let (Some(ChatItem::Tool(old)), ChatItem::Tool(new)) = (self.items.get(ix), &mut item) |
| 4632 | 4699 | && old.id == new.id |
| @@ -4735,14 +4802,44 @@ fn read_image(path: &Path) -> Result<String, String> { |
| 4735 | 4802 | } |
| 4736 | 4803 | |
| 4737 | 4804 | impl AssistantText { |
| 4805 | + /// An answer showing the first `shown` bytes of `text`, with no view yet. |
| 4806 | + fn new(text: String, shown: usize) -> Self { |
| 4807 | + Self { text, view: None, shown } |
| 4808 | + } |
| 4809 | + |
| 4810 | + /// The Markdown view of what the answer shows, made on first use. |
| 4811 | + pub fn view(&mut self, cx: &mut App) -> Entity<TextViewState> { |
| 4812 | + let shown = &self.text[..self.shown]; |
| 4813 | + self.view.get_or_insert_with(|| cx.new(|cx| TextViewState::markdown(shown, cx).selectable(true))).clone() |
| 4814 | + } |
| 4815 | + |
| 4816 | + /// Drops the view; the next draw makes it again from the text. |
| 4817 | + fn release_view(&mut self) { |
| 4818 | + self.view = None; |
| 4819 | + } |
| 4820 | + |
| 4738 | 4821 | /// Moves the view up to `end` bytes of the text. |
| 4739 | 4822 | fn show(&mut self, end: usize, cx: &mut App) { |
| 4740 | 4823 | if end > self.shown && self.text.is_char_boundary(end) { |
| 4741 | | - let new = self.text[self.shown..end].to_string(); |
| 4742 | | - self.view.update(cx, |state, cx| state.push_str(&new, cx)); |
| 4824 | + // Without a view there's nothing to feed: it's made from the |
| 4825 | + // text when it's first drawn. |
| 4826 | + if let Some(view) = &self.view { |
| 4827 | + let new = self.text[self.shown..end].to_string(); |
| 4828 | + view.update(cx, |state, cx| state.push_str(&new, cx)); |
| 4829 | + } |
| 4743 | 4830 | self.shown = end; |
| 4744 | 4831 | } |
| 4745 | 4832 | } |
| 4833 | + |
| 4834 | + /// Replaces the whole text, and shows all of it. |
| 4835 | + fn replace(&mut self, text: String, cx: &mut App) { |
| 4836 | + self.text = text; |
| 4837 | + self.shown = self.text.len(); |
| 4838 | + if let Some(view) = &self.view { |
| 4839 | + let text = self.text.clone(); |
| 4840 | + view.update(cx, |view, cx| view.set_text(&text, cx)); |
| 4841 | + } |
| 4842 | + } |
| 4746 | 4843 | } |
| 4747 | 4844 | |
| 4748 | 4845 | /// How much of streamed Markdown is finished: up to the last blank line |
| @@ -4859,7 +4956,7 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String |
| 4859 | 4956 | |
| 4860 | 4957 | #[cfg(test)] |
| 4861 | 4958 | mod tests { |
| 4862 | | - use super::{TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; |
| 4959 | + use super::{AssistantText, TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; |
| 4863 | 4960 | use serde_json::{Value, json}; |
| 4864 | 4961 | |
| 4865 | 4962 | fn tool(name: &str, input: Value) -> ToolCall { |
| @@ -4930,4 +5027,16 @@ mod tests { |
| 4930 | 5027 | assert_eq!(finished_prefix(closed), "```rust\nfn a() {}\n```\n".len()); |
| 4931 | 5028 | assert_eq!(finished_prefix("Ünïcödé.\n\nMore"), "Ünïcödé.\n\n".len()); |
| 4932 | 5029 | } |
| 5030 | + |
| 5031 | + /// An answer without a view yet keeps track of how much it shows, so the |
| 5032 | + /// view made later starts from exactly that, with nothing lost or doubled. |
| 5033 | + #[::core::prelude::v1::test] |
| 5034 | + fn answer_without_view_tracks_what_it_shows() { |
| 5035 | + let mut answer = AssistantText::new("First.\n\nSecond".into(), 0); |
| 5036 | + assert!(answer.view.is_none()); |
| 5037 | + assert_eq!(answer.shown, 0); |
| 5038 | + answer.release_view(); |
| 5039 | + assert!(answer.view.is_none()); |
| 5040 | + assert_eq!(answer.text, "First.\n\nSecond"); |
| 5041 | + } |
| 4933 | 5042 | } |