Vectrum
Sign In

Agents start when a message is on its way and stop when idle; answers build their Markdown on first draw

A new thread no longer spawns its agent at once: typing in the composer warms it up, and the first message starts it otherwise. An idle thread's agent stops after 15 minutes (Settings → General → Stop idle agents), except the open thread's; the next message resumes the conversation. Assistant answers make their Markdown view the first time they're drawn, and a settled thread drops them. Each chat view ticks only while its thread works.
JjohannSocommitted 1 parent 2c864abcommit6676930

Showing 9 changed files with 209 additions and 50 deletions.

+4−0README.mdView File
@@ -181,6 +181,10 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
181181 Paste, Select all and Jump to latest; Ctrl+Shift+C / Ctrl+Shift+V copy and paste.
182182 - Interrupt with Esc or the stop button. Messages sent while the agent works are queued.
183183 - Crash recovery: if a process dies, the next message restarts it and resumes the session.
184+ - Agents run only while they're needed: a new thread's agent starts once you begin typing,
185+ and an idle thread's agent stops after 15 minutes without activity (Settings → General →
186+ **Stop idle agents**; 0 keeps them running) to give its memory back. The open thread's
187+ agent stays up, and the next message resumes the conversation in a new one.
184188 - Threads are saved as you go and come back after a restart, settled ones included; the next
185189 message resumes the conversation. Optionally, threads a crash or restart interrupted
186190 continue on their own.
+140−31src/session.rsView File
@@ -634,9 +634,13 @@ pub struct Question {
634634 /// An answer as the agent wrote it, and the Markdown view showing it. While
635635 /// streaming, the view may lag behind the text to show only finished
636636 /// 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.
637641 pub struct AssistantText {
638642 pub text: String,
639- pub view: Entity<TextViewState>,
643+ view: Option<Entity<TextViewState>>,
640644 /// How much of `text` the view shows.
641645 shown: usize,
642646 }
@@ -957,7 +961,10 @@ pub struct NewSession {
957961 impl EventEmitter<SessionEvent> for Session {}
958962
959963 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 {
961968 let now = SystemTime::now();
962969 let id = format!(
963970 "{:x}-{:x}-{}",
@@ -965,16 +972,26 @@ impl Session {
965972 std::process::id(),
966973 NEXT_SESSION.load(Ordering::Relaxed)
967974 );
968- let start = new.worktree.is_none();
969975 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;
975977 session
976978 }
977979
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+
978995 /// A subagent's thread: it runs a task `parent` delegated, in the
979996 /// parent's working folder, and starts on the task right away.
980997 pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self {
@@ -1967,6 +1984,39 @@ impl Session {
19671984 true
19681985 }
19691986
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+
19702020 /// Moves a fresh thread into another thread's worktree, as "New thread on
19712021 /// {branch}" does for a worktree thread, restarting its agent there.
19722022 pub fn adopt_worktree(&mut self, worktree: Worktree, cwd: PathBuf, cx: &mut Context<Self>) {
@@ -2056,6 +2106,9 @@ impl Session {
20562106 }
20572107 self.settled = true;
20582108 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();
20592112 cx.notify();
20602113 }
20612114
@@ -2283,6 +2336,25 @@ impl Session {
22832336 Some(FinishedTurn { first, answer, end, duration_ms, cost, at, has_work })
22842337 }
22852338
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+
22862358 pub fn toggle_tool(&mut self, ix: usize, cx: &mut Context<Self>) {
22872359 if let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) {
22882360 tool.expanded = !tool.expanded;
@@ -2784,7 +2856,7 @@ impl Session {
27842856 let item_ix = match block.get("type").and_then(Value::as_str) {
27852857 Some("text") => {
27862858 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))
27882860 }
27892861 Some("tool_use") => Some(self.push_tool(
27902862 block.get("id").and_then(Value::as_str).unwrap_or_default(),
@@ -2862,7 +2934,7 @@ impl Session {
28622934
28632935 /// Complete assistant messages. Content we already streamed is skipped;
28642936 /// 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>) {
28662938 let already_streamed = message
28672939 .get("id")
28682940 .and_then(Value::as_str)
@@ -2874,7 +2946,7 @@ impl Session {
28742946 Some("text") if !already_streamed => {
28752947 let text = block.get("text").and_then(Value::as_str).unwrap_or_default();
28762948 if !text.trim().is_empty() {
2877- self.push_assistant_text(text, cx);
2949+ self.push_assistant_text(text);
28782950 }
28792951 }
28802952 Some("tool_use") => {
@@ -3288,7 +3360,7 @@ impl Session {
32883360 }
32893361 match event.get("type").and_then(Value::as_str) {
32903362 Some("text_start") => {
3291- let ix = self.push_assistant_text("", cx);
3363+ let ix = self.push_assistant_text("");
32923364 self.map_block(index, ix);
32933365 }
32943366 Some("text_delta") => {
@@ -3296,7 +3368,7 @@ impl Session {
32963368 let ix = match self.streamed_item(index) {
32973369 Some(ix) => ix,
32983370 None => {
3299- let ix = self.push_assistant_text("", cx);
3371+ let ix = self.push_assistant_text("");
33003372 self.map_block(index, ix);
33013373 ix
33023374 }
@@ -3483,10 +3555,7 @@ impl Session {
34833555 "text_replace" => {
34843556 let ix = self.jcode_text_item(text("message_id"), cx);
34853557 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);
34903559 }
34913560 }
34923561 "text_done" => {
@@ -3674,11 +3743,11 @@ impl Session {
36743743 }
36753744
36763745 /// 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 {
36783747 if let Some(&ix) = self.jcode.texts.get(message_id) {
36793748 return ix;
36803749 }
3681- let ix = self.push_assistant_text("", cx);
3750+ let ix = self.push_assistant_text("");
36823751 self.jcode.texts.insert(message_id.to_string(), ix);
36833752 ix
36843753 }
@@ -3821,9 +3890,8 @@ impl Session {
38213890
38223891 // ---- helpers -----------------------------------------------------
38233892
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())));
38273895 self.items.len() - 1
38283896 }
38293897
@@ -4191,7 +4259,7 @@ impl Session {
41914259
41924260 /// Brings a saved thread back. Its agent starts again, resuming the
41934261 /// 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 {
41954263 let new = NewSession {
41964264 instance: saved.instance,
41974265 provider: saved.provider,
@@ -4232,7 +4300,7 @@ impl Session {
42324300 session.turn_changes = saved.turn_changes.into_iter().collect();
42334301 session.delegation = saved.delegation;
42344302 for item in saved.items {
4235- let item = session.item_from_saved(item, false, cx);
4303+ let item = session.item_from_saved(item, false);
42364304 session.items.push(item);
42374305 }
42384306 if session.interrupted {
@@ -4244,7 +4312,7 @@ impl Session {
42444312 /// Turns a saved item back into a chat item. `live` keeps pending
42454313 /// prompts and questions pending (a host's transcript); otherwise they
42464314 /// 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 {
42484316 match item {
42494317 SavedItem::User { text, attachments, at } => ChatItem::User {
42504318 text: text.into(),
@@ -4252,9 +4320,8 @@ impl Session {
42524320 at: from_millis(at),
42534321 },
42544322 SavedItem::Assistant { text } => {
4255- let view = cx.new(|cx| TextViewState::markdown(&text, cx).selectable(true));
42564323 let shown = text.len();
4257- ChatItem::Assistant(AssistantText { text, view, shown })
4324+ ChatItem::Assistant(AssistantText::new(text, shown))
42584325 }
42594326 SavedItem::Tool {
42604327 id,
@@ -4626,7 +4693,7 @@ impl Session {
46264693 answer.show(end, cx);
46274694 continue;
46284695 }
4629- let mut item = self.item_from_saved(saved, true, cx);
4696+ let mut item = self.item_from_saved(saved, true);
46304697 // Keep tool cards open across updates.
46314698 if let (Some(ChatItem::Tool(old)), ChatItem::Tool(new)) = (self.items.get(ix), &mut item)
46324699 && old.id == new.id
@@ -4735,14 +4802,44 @@ fn read_image(path: &Path) -> Result<String, String> {
47354802 }
47364803
47374804 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+
47384821 /// Moves the view up to `end` bytes of the text.
47394822 fn show(&mut self, end: usize, cx: &mut App) {
47404823 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+ }
47434830 self.shown = end;
47444831 }
47454832 }
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+ }
47464843 }
47474844
47484845 /// 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
48594956
48604957 #[cfg(test)]
48614958 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};
48634960 use serde_json::{Value, json};
48644961
48654962 fn tool(name: &str, input: Value) -> ToolCall {
@@ -4930,4 +5027,16 @@ mod tests {
49305027 assert_eq!(finished_prefix(closed), "```rust\nfn a() {}\n```\n".len());
49315028 assert_eq!(finished_prefix("Ünïcödé.\n\nMore"), "Ünïcödé.\n\n".len());
49325029 }
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+ }
49335042 }
+6−0src/settings.rsView File
@@ -546,6 +546,10 @@ pub struct Settings {
546546 pub background_activity: BackgroundActivity,
547547 /// Seconds between provider health checks; 0 turns them off.
548548 pub health_check_secs: u64,
549+ /// Minutes an idle thread's agent process keeps running before it's
550+ /// stopped to free its memory; the next message resumes the
551+ /// conversation. 0 keeps them running.
552+ pub idle_agent_minutes: u64,
549553
550554 // ---- integrations ----
551555 /// Let agents use the browser panel's shared browser.
@@ -666,6 +670,7 @@ impl Default for Settings {
666670 update_checks: true,
667671 background_activity: BackgroundActivity::Balanced,
668672 health_check_secs: 300,
673+ idle_agent_minutes: 15,
669674 agent_browser: true,
670675 auto_float_preview: true,
671676 enable_device_support: false,
@@ -758,6 +763,7 @@ impl Settings {
758763 ("Continue threads after restarts", self.continue_after_restart != d.continue_after_restart),
759764 ("Background activity", self.background_activity != d.background_activity),
760765 ("Health check interval", self.health_check_secs != d.health_check_secs),
766+ ("Stop idle agents", self.idle_agent_minutes != d.idle_agent_minutes),
761767 ("Default model", (&self.default_model, &self.default_reasoning) != (&d.default_model, &d.default_reasoning)),
762768 ("Permissions", self.default_access != d.default_access),
763769 ("New thread mode", self.workspace != d.workspace),
+31−18src/ui/chat.rsView File
@@ -220,7 +220,8 @@ pub struct ChatView {
220220 /// the button is released. If it turns into a drag, the drag selects.
221221 multi_press: Option<Point<Pixels>>,
222222 _subscriptions: Vec<Subscription>,
223- _ticker: Task<()>,
223+ /// Ticks once a second while the agent works.
224+ _ticker: Option<Task<()>>,
224225 }
225226
226227 impl EventEmitter<ChatEvent> for ChatView {}
@@ -240,6 +241,7 @@ impl ChatView {
240241 let subscriptions = vec![
241242 cx.observe_in(&session, window, |this, session, window, cx| {
242243 this.sync_with_session(window, cx);
244+ this.sync_ticker(cx);
243245 let count = session.read(cx).items.len();
244246 this.scroller.update(cx, |scroller, cx| {
245247 let current = scroller.item_count();
@@ -267,6 +269,11 @@ impl ChatView {
267269 this.sync_custom_answer(cx);
268270 this.expand(cx);
269271 this.load_files_if_mentioning(cx);
272+ // A new thread's agent starts once there's a message on
273+ // the way, so it's ready by the time it's sent.
274+ if !this.composer.read(cx).value().trim().is_empty() {
275+ this.session.update(cx, |session, cx| session.warm_up(cx));
276+ }
270277 cx.notify();
271278 }
272279 InputEvent::Focus => this.expand(cx),
@@ -296,22 +303,6 @@ impl ChatView {
296303 cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)),
297304 ];
298305
299- // Re-render once a second while the agent works so the timer advances.
300- let ticker = cx.spawn(async move |this, cx| {
301- loop {
302- cx.background_executor().timer(Duration::from_secs(1)).await;
303- let alive = this.update(cx, |this, cx| {
304- let session = this.session.read(cx);
305- if session.is_working() || !session.background_tasks.is_empty() {
306- cx.notify();
307- }
308- });
309- if alive.is_err() {
310- break;
311- }
312- }
313- });
314-
315306 let mut this = Self {
316307 session,
317308 composer,
@@ -349,12 +340,34 @@ impl ChatView {
349340 was_scrolled_up: false,
350341 multi_press: None,
351342 _subscriptions: subscriptions,
352- _ticker: ticker,
343+ _ticker: None,
353344 };
354345 this.sync_with_session(window, cx);
346+ this.sync_ticker(cx);
355347 this
356348 }
357349
350+ /// Re-renders once a second while the agent works, so its timer
351+ /// advances; an idle thread's view doesn't wake at all.
352+ fn sync_ticker(&mut self, cx: &mut Context<Self>) {
353+ let session = self.session.read(cx);
354+ let busy = session.is_working() || !session.background_tasks.is_empty();
355+ match (busy, self._ticker.is_some()) {
356+ (true, false) => {
357+ self._ticker = Some(cx.spawn(async move |this, cx| {
358+ loop {
359+ cx.background_executor().timer(Duration::from_secs(1)).await;
360+ if this.update(cx, |_, cx| cx.notify()).is_err() {
361+ break;
362+ }
363+ }
364+ }));
365+ }
366+ (false, true) => self._ticker = None,
367+ _ => {}
368+ }
369+ }
370+
358371 pub fn session(&self) -> &Entity<Session> {
359372 &self.session
360373 }
+6−1src/ui/chat/timeline.rsView File
@@ -381,6 +381,8 @@ pub(super) fn render_item(
381381 cx: &mut App,
382382 ) -> AnyElement {
383383 let p = metal::palette(cx);
384+ // An answer's Markdown view is made the first time it's drawn.
385+ let answer_view = session.update(cx, |session, cx| session.answer_view(ix, cx));
384386 let state = session.read(cx);
385387 let Some(item) = state.items.get(ix) else {
386388 return div().into_any_element();
@@ -434,7 +436,10 @@ pub(super) fn render_item(
434436 bottom = px(16.);
435437 }
436438 let icx = crate::ui::inline::InlineCx { session: session.downgrade(), ix, scroller: scroller.downgrade() };
437- render_assistant(ix, &answer.text, &answer.view, icx, meta, p)
439+ match &answer_view {
440+ Some(view) => render_assistant(ix, &answer.text, view, icx, meta, p),
441+ None => div().into_any_element(),
442+ }
438443 }
439444 ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p),
440445 ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => {
+12−0src/ui/settings/general.rsView File
@@ -465,6 +465,18 @@ impl SettingsView {
465465 settings.background_activity = activity
466466 })),
467467 )
468+ .row(
469+ row("Stop idle agents")
470+ .id("stop-idle-agents")
471+ .description(
472+ "Stops a thread's agent process after this many minutes without activity, to free its memory. \
473+ The next message resumes the conversation. Set to 0 to keep agents running.",
474+ )
475+ .reset(reset(&view, cx, |s| s.idle_agent_minutes, |s, minutes| s.idle_agent_minutes = minutes))
476+ .control(stepper("idle-agent-minutes", saved.idle_agent_minutes, 5, (0, 1440), "min", &view, p, |settings, minutes| {
477+ settings.idle_agent_minutes = minutes
478+ })),
479+ )
468480 .render(cx);
469481
470482 // ---- Projects & threads ----
+1−0src/ui/settings/search.rsView File
@@ -95,6 +95,7 @@ pub const ITEMS: &[Item] = &[
9595 "resume running active interrupted work restart reboot machine crash update automatically",
9696 ),
9797 item("background-activity", "Background activity", Page::General, "balanced performance battery saver git provider health refresh power idle policy"),
98+ item("stop-idle-agents", "Stop idle agents", Page::General, "memory ram process idle agent stop free resume minutes"),
9899 item("new-threads", "Workspace", Page::General, "new threads default workspace mode local worktree checkout"),
99100 item("worktree-submodules", "Submodules", Page::General, "git submodule init recursive top-level none worktree"),
100101 item("start-from-origin", "Start from origin", Page::General, "new worktrees latest matching remote branch local"),
+8−0src/ui/workspace.rsView File
@@ -770,6 +770,14 @@ impl Workspace {
770770 session.update(cx, |session, cx| session.settle(cx));
771771 }
772772 }
773+ // Idle agents give their memory back; the open thread's stays warm.
774+ let idle_minutes = settings::get(|settings| settings.idle_agent_minutes);
775+ if idle_minutes > 0 {
776+ let after = Duration::from_secs(idle_minutes * 60);
777+ for session in sessions.iter().filter(|session| self.active.as_ref() != Some(*session)) {
778+ session.update(cx, |session, cx| session.release_idle_agent(after, cx));
779+ }
780+ }
773781 if allowed && self.last_pr_check.is_none_or(|checked| checked.elapsed() >= PR_CHECK) {
774782 self.last_pr_check = Some(Instant::now());
775783 self.check_pull_requests(cx);
+1−0src/ui/workspace/palette.rsView File
@@ -255,6 +255,7 @@ const SETTINGS: &[(&str, &str)] = &[
255255 ("Send shortcut", "Behavior"),
256256 ("Follow-up behavior", "Behavior"),
257257 ("Background activity", "Behavior"),
258+ ("Stop idle agents", "Behavior"),
258259 ("Thread cards", "Appearance"),
259260 ("Theme", "Themes"),
260261 ("Providers", "Providers"),