use newt_core::lifecycle::SessionId;
use newt_core::prompt::TurnPromptContext;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PersonaSelection {
Absent,
Named(String),
SyntheticAltitude(newt_core::Altitude),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TabSidecar {
pub personality: newt_core::role_profile::PersonalityTraits,
pub persona_selection: Option<PersonaSelection>,
pub turns_this_conversation: u32,
pub last_resume_listing: Vec<String>,
pub active_roadmap_id: Option<String>,
pub interrupted_objective: Option<TurnPromptContext>,
}
#[derive(Debug, Clone, Default)]
pub struct FreshSeed {
pub persona: Option<crate::Persona>,
pub pending: newt_core::PreferenceActions,
}
#[derive(Debug, Clone)]
pub struct TabState {
session_id: SessionId,
conversation_id: String,
pub sidecar: TabSidecar,
pub input_stash: String,
pub fresh_seed: Option<FreshSeed>,
pub pin_degraded: Option<crate::PinDegraded>,
}
impl TabState {
#[must_use]
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
#[must_use]
pub fn conversation_id(&self) -> &str {
&self.conversation_id
}
pub fn hold_conversation(&mut self, conversation_id: impl Into<String>) {
let conversation_id = conversation_id.into();
if self.conversation_id != conversation_id {
self.sidecar.personality = Default::default();
self.sidecar.persona_selection = None;
self.conversation_id = conversation_id;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TabError {
OutOfRange { open: usize },
LastTab,
PreflightFailed { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "an activation that never applies its handoff leaves lifecycle \
events attributed to the tab the operator just left"]
pub struct OwnerHandoff {
pub from: Option<SessionId>,
pub to: SessionId,
}
impl OwnerHandoff {
pub fn apply(&self) {
newt_core::lifecycle::set_active_session(&self.to);
if self.from.as_ref() != Some(&self.to) {
newt_core::lifecycle::emit_for(
Some(self.to.to_string()),
newt_core::lifecycle::LifecycleEvent::SessionStarted {
session_id: self.to.to_string(),
},
);
}
}
}
#[derive(Debug, Clone)]
pub struct TabSet {
tabs: Vec<TabState>,
active: usize,
}
#[allow(clippy::len_without_is_empty)]
impl TabSet {
#[must_use]
pub fn new(session_id: SessionId, conversation_id: impl Into<String>) -> Self {
Self {
tabs: vec![TabState {
session_id,
conversation_id: conversation_id.into(),
sidecar: TabSidecar::default(),
input_stash: String::new(),
fresh_seed: None,
pin_degraded: None,
}],
active: 0,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.tabs.len()
}
#[must_use]
pub fn active_index(&self) -> usize {
self.active
}
#[must_use]
pub fn active(&self) -> &TabState {
&self.tabs[self.active]
}
pub fn active_mut(&mut self) -> &mut TabState {
&mut self.tabs[self.active]
}
#[must_use]
pub fn tabs(&self) -> &[TabState] {
&self.tabs
}
pub fn tabs_mut(&mut self) -> &mut [TabState] {
&mut self.tabs
}
#[must_use]
pub fn get(&self, index: usize) -> Option<&TabState> {
self.tabs.get(index)
}
#[must_use]
pub fn find_by_conversation(&self, conversation_id: &str) -> Option<usize> {
self.tabs
.iter()
.position(|tab| tab.conversation_id == conversation_id)
}
pub fn open(
&mut self,
session_id: SessionId,
conversation_id: impl Into<String>,
) -> (usize, OwnerHandoff) {
let from = self.active().session_id.clone();
let at = self.active + 1;
self.tabs.insert(
at,
TabState {
session_id: session_id.clone(),
conversation_id: conversation_id.into(),
sidecar: TabSidecar::default(),
input_stash: String::new(),
fresh_seed: None,
pin_degraded: None,
},
);
self.active = at;
(
at,
OwnerHandoff {
from: Some(from),
to: session_id,
},
)
}
pub fn activate(&mut self, index: usize) -> Result<OwnerHandoff, TabError> {
if index >= self.tabs.len() {
return Err(TabError::OutOfRange {
open: self.tabs.len(),
});
}
let from = self.active().session_id.clone();
self.active = index;
Ok(OwnerHandoff {
from: Some(from),
to: self.tabs[index].session_id.clone(),
})
}
pub fn close(&mut self, index: usize) -> Result<Closed, TabError> {
if index >= self.tabs.len() {
return Err(TabError::OutOfRange {
open: self.tabs.len(),
});
}
if self.tabs.len() == 1 {
return Err(TabError::LastTab);
}
let removed = self.tabs.remove(index);
if self.active > index || self.active == self.tabs.len() {
self.active -= 1;
}
Ok(Closed { tab: removed })
}
pub fn move_tab(&mut self, from: usize, to: usize) -> Result<(), TabError> {
let open = self.tabs.len();
if from >= open || to >= open {
return Err(TabError::OutOfRange { open });
}
let active_session = self.active().session_id.clone();
let tab = self.tabs.remove(from);
self.tabs.insert(to, tab);
self.active = self
.tabs
.iter()
.position(|t| t.session_id == active_session)
.unwrap_or(self.active.min(open - 1));
Ok(())
}
#[must_use]
pub fn claimed_conversations(&self) -> Vec<&str> {
self.tabs
.iter()
.map(|tab| tab.conversation_id.as_str())
.collect()
}
}
#[derive(Debug, Clone)]
pub struct Closed {
pub tab: TabState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TabAction {
List,
New,
Next,
Prev(usize),
Goto(usize),
Close(Option<usize>),
Move { from: usize, to: usize },
Retry,
Rename { index: Option<usize>, title: String },
}
pub fn parse_tab_command(args: &str) -> Result<TabAction, String> {
let args = args.trim();
if args.is_empty() {
return Ok(TabAction::List);
}
let mut parts = args.split_whitespace();
let head = parts.next().unwrap_or_default();
let rest: Vec<&str> = parts.collect();
if let Ok(n) = head.parse::<usize>() {
return if rest.is_empty() {
positional(n).map(TabAction::Goto)
} else {
Err(format!("usage: /tab {n} (no extra arguments)"))
};
}
match head {
"new" => Ok(TabAction::New),
"retry" => Ok(TabAction::Retry),
"next" => Ok(TabAction::Next),
"prev" => Ok(TabAction::Prev(1)),
"close" => match rest.as_slice() {
[] => Ok(TabAction::Close(None)),
[n] => positional(parse_index(n)?).map(|i| TabAction::Close(Some(i))),
_ => Err("usage: /tab close [n]".to_string()),
},
"move" => match rest.as_slice() {
[from, to] => Ok(TabAction::Move {
from: positional(parse_index(from)?)?,
to: positional(parse_index(to)?)?,
}),
_ => Err("usage: /tab move <from> <to>".to_string()),
},
"rename" => {
if rest.is_empty() {
return Err("usage: /tab rename [n] <title>".to_string());
}
match rest[0].parse::<usize>() {
Ok(n) if rest.len() > 1 => Ok(TabAction::Rename {
index: Some(positional(n)?),
title: rest[1..].join(" "),
}),
_ => Ok(TabAction::Rename {
index: None,
title: rest.join(" "),
}),
}
}
other => Err(format!(
"unknown: /tab {other} — try /tab, /tab new, /tab <n>, \
/tab next|prev, /tab close [n], /tab move <a> <b>, \
/tab rename [n] <title>, /tab retry"
)),
}
}
fn parse_index(raw: &str) -> Result<usize, String> {
raw.parse::<usize>()
.map_err(|_| format!("'{raw}' is not a tab number"))
}
fn positional(n: usize) -> Result<usize, String> {
n.checked_sub(1)
.ok_or_else(|| "tabs are numbered from 1".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn sid(n: u8) -> SessionId {
SessionId::from_issued(format!("session-{n}"))
}
fn three_tabs() -> TabSet {
let mut set = TabSet::new(sid(1), "conv-1");
let _ = set.open(sid(2), "conv-2");
let _ = set.open(sid(3), "conv-3");
let _ = set.activate(0).unwrap();
set
}
#[test]
fn bare_tab_lists() {
assert_eq!(parse_tab_command(""), Ok(TabAction::List));
assert_eq!(parse_tab_command(" "), Ok(TabAction::List));
}
#[test]
fn a_bare_number_activates_that_tab_one_based() {
assert_eq!(parse_tab_command("2"), Ok(TabAction::Goto(1)));
assert_eq!(parse_tab_command(" 1 "), Ok(TabAction::Goto(0)));
}
#[test]
fn tab_zero_is_refused_rather_than_silently_meaning_tab_one() {
assert!(parse_tab_command("0").is_err());
assert!(parse_tab_command("close 0").is_err());
assert!(parse_tab_command("move 0 1").is_err());
}
#[test]
fn the_verb_forms_parse() {
assert_eq!(parse_tab_command("new"), Ok(TabAction::New));
assert_eq!(parse_tab_command("next"), Ok(TabAction::Next));
assert_eq!(parse_tab_command("prev"), Ok(TabAction::Prev(1)));
assert_eq!(parse_tab_command("close"), Ok(TabAction::Close(None)));
assert_eq!(parse_tab_command("close 3"), Ok(TabAction::Close(Some(2))));
assert_eq!(
parse_tab_command("move 1 3"),
Ok(TabAction::Move { from: 0, to: 2 })
);
}
#[test]
fn rename_targets_the_active_tab_unless_a_number_leads() {
assert_eq!(
parse_tab_command("rename ship the thing"),
Ok(TabAction::Rename {
index: None,
title: "ship the thing".to_string()
})
);
assert_eq!(
parse_tab_command("rename 2 ship the thing"),
Ok(TabAction::Rename {
index: Some(1),
title: "ship the thing".to_string()
})
);
assert_eq!(
parse_tab_command("rename 42"),
Ok(TabAction::Rename {
index: None,
title: "42".to_string()
})
);
assert!(parse_tab_command("rename").is_err());
}
#[test]
fn an_unknown_verb_names_the_whole_family_rather_than_just_refusing() {
let err = parse_tab_command("frobnicate").unwrap_err();
assert!(err.contains("/tab new"), "{err}");
assert!(err.contains("/tab move"), "{err}");
}
#[test]
fn arity_mistakes_are_refused_with_usage() {
assert!(parse_tab_command("2 3").is_err());
assert!(parse_tab_command("move 1").is_err());
assert!(parse_tab_command("close 1 2").is_err());
assert!(parse_tab_command("move a b").is_err());
}
#[test]
fn each_tab_gets_a_distinct_stable_session_id() {
let set = three_tabs();
let ids: std::collections::BTreeSet<_> =
set.tabs().iter().map(|t| t.session_id().clone()).collect();
assert_eq!(ids.len(), 3, "one identity per tab, all distinct");
}
#[test]
fn opening_a_tab_inserts_after_the_active_one_and_activates_it() {
let mut set = TabSet::new(sid(1), "conv-1");
let (idx, handoff) = set.open(sid(2), "conv-2");
assert_eq!(idx, 1);
assert_eq!(set.active_index(), 1);
assert_eq!(handoff.from, Some(sid(1)));
assert_eq!(handoff.to, sid(2));
let _ = set.activate(0).unwrap();
let (idx, _) = set.open(sid(3), "conv-3");
assert_eq!(idx, 1, "inserted directly after the active tab");
assert_eq!(
set.tabs()
.iter()
.map(|t| t.conversation_id())
.collect::<Vec<_>>(),
vec!["conv-1", "conv-3", "conv-2"]
);
}
#[test]
fn switching_tabs_does_not_mint_a_new_session_id() {
let mut set = three_tabs();
let before: Vec<_> = set.tabs().iter().map(|t| t.session_id().clone()).collect();
let _ = set.activate(2).unwrap();
let _ = set.activate(1).unwrap();
let _ = set.activate(0).unwrap();
let after: Vec<_> = set.tabs().iter().map(|t| t.session_id().clone()).collect();
assert_eq!(before, after, "switching is not creating");
}
#[test]
fn a_to_b_then_back_restores_a_s_same_session_id() {
let mut set = three_tabs();
let a = set.active().session_id().clone();
let to_b = set.activate(1).unwrap();
assert_eq!(to_b.from, Some(a.clone()));
let back = set.activate(0).unwrap();
assert_eq!(
back.to, a,
"A→B→A returns to the SAME session, not a new one"
);
}
#[test]
fn a_conversation_switch_inside_a_tab_keeps_the_tab_s_session_id() {
let mut set = TabSet::new(sid(1), "conv-1");
let before = set.active().session_id().clone();
set.active_mut().hold_conversation("conv-99");
assert_eq!(set.active().conversation_id(), "conv-99");
assert_eq!(
*set.active().session_id(),
before,
"resuming another conversation does not make this a different session"
);
}
#[test]
fn personality_overrides_are_tab_local_and_reset_only_for_a_new_conversation() {
use newt_core::role_profile::{PersonalityLevel, PersonalityTraits};
let mut set = TabSet::new(sid(1), "conv-1");
let a = PersonalityTraits {
warmth: Some(PersonalityLevel::try_from(80).unwrap()),
..Default::default()
};
set.active_mut().sidecar.personality = a;
set.active_mut().sidecar.persona_selection = Some(PersonaSelection::Named("steady".into()));
let (second, _) = set.open(sid(2), "conv-2");
let _ = set.activate(second).unwrap();
assert_eq!(
set.active().sidecar.personality,
PersonalityTraits::default()
);
set.active_mut().sidecar.personality.extraversion =
Some(PersonalityLevel::try_from(0).unwrap());
let _ = set.activate(0).unwrap();
assert_eq!(
set.active().sidecar.personality,
a,
"return to A's own choices"
);
set.active_mut().hold_conversation("conv-1");
assert_eq!(
set.active().sidecar.persona_selection,
Some(PersonaSelection::Named("steady".into()))
);
assert_eq!(
set.active().sidecar.personality,
a,
"reconciliation is not a reset"
);
set.active_mut().hold_conversation("conv-3");
assert_eq!(set.active().sidecar.persona_selection, None);
assert_eq!(
set.active().sidecar.personality,
PersonalityTraits::default()
);
let _ = set.activate(second).unwrap();
assert_eq!(
set.active().sidecar.personality.extraversion,
Some(PersonalityLevel::try_from(0).unwrap()),
"B was not reset with A"
);
}
#[test]
fn activation_is_bounds_checked() {
let mut set = three_tabs();
assert_eq!(
set.activate(3),
Err(TabError::OutOfRange { open: 3 }),
"the error carries the open count so the caller can say 1..=3"
);
assert_eq!(set.active_index(), 0, "a refused switch moves nothing");
}
#[test]
fn activating_the_active_tab_is_allowed() {
let mut set = three_tabs();
let handoff = set
.activate(0)
.expect("/tab 1 while on tab 1 is not an error");
assert_eq!(handoff.from, Some(handoff.to.clone()));
}
#[test]
fn closing_the_last_tab_is_refused() {
let mut set = TabSet::new(sid(1), "conv-1");
assert_eq!(set.close(0).unwrap_err(), TabError::LastTab);
assert_eq!(set.len(), 1, "the refusal changed nothing");
}
#[test]
fn closing_the_active_tab_hands_off_to_a_neighbor_before_release() {
let mut set = three_tabs();
let _ = set.activate(1).unwrap(); let closed = set.close(1).unwrap();
assert_eq!(closed.tab.conversation_id(), "conv-2");
assert_eq!(
set.active_index(),
1,
"the active index still names an open tab"
);
assert!(
!set.tabs().iter().any(|t| *t.session_id() == sid(2)),
"the closed tab is gone from the set"
);
}
#[test]
fn closing_an_inactive_tab_does_not_disturb_the_active_one() {
let mut set = three_tabs();
let _ = set.activate(2).unwrap();
let active = set.active().session_id().clone();
let closed = set.close(0).unwrap();
assert_eq!(closed.tab.conversation_id(), "conv-1");
assert_eq!(
*set.active().session_id(),
active,
"closing another tab cannot end or re-anchor this one"
);
assert_eq!(set.active_index(), 1, "the index shifted with the removal");
}
#[test]
fn closing_the_last_positional_tab_while_active_steps_back() {
let mut set = three_tabs();
let _ = set.activate(2).unwrap();
let _closed = set.close(2).unwrap();
assert_eq!(set.active_index(), 1, "active never dangles past the end");
assert_eq!(set.len(), 2);
}
#[test]
fn tabs_renumber_positionally_on_close() {
let mut set = three_tabs();
set.close(0).unwrap();
assert_eq!(
set.tabs()
.iter()
.map(|t| t.conversation_id())
.collect::<Vec<_>>(),
vec!["conv-2", "conv-3"],
"what was tab 2 is now tab 1 — <n>gt is positional-absolute"
);
}
#[test]
fn moving_a_tab_preserves_which_tab_is_active() {
let mut set = three_tabs();
let _ = set.activate(1).unwrap();
let active = set.active().session_id().clone();
set.move_tab(0, 2).unwrap();
assert_eq!(
*set.active().session_id(),
active,
"reordering never silently activates a different tab"
);
}
#[test]
fn moving_tracks_identity_by_session_not_conversation() {
let mut set = three_tabs();
let _ = set.activate(1).unwrap();
set.active_mut().hold_conversation("conv-swapped");
let active = set.active().session_id().clone();
set.move_tab(1, 0).unwrap();
assert_eq!(*set.active().session_id(), active);
assert_eq!(set.active_index(), 0);
}
#[test]
fn move_is_bounds_checked() {
let mut set = three_tabs();
assert_eq!(set.move_tab(0, 9), Err(TabError::OutOfRange { open: 3 }));
assert_eq!(set.move_tab(9, 0), Err(TabError::OutOfRange { open: 3 }));
}
#[test]
fn a_conversation_open_in_a_tab_is_found_so_resume_becomes_activation() {
let set = three_tabs();
assert_eq!(set.find_by_conversation("conv-2"), Some(1));
assert_eq!(set.find_by_conversation("conv-absent"), None);
}
#[test]
fn every_open_tab_s_conversation_is_reported_for_release() {
let set = three_tabs();
assert_eq!(
set.claimed_conversations(),
vec!["conv-1", "conv-2", "conv-3"],
"exit releases exactly the claims the tabs hold"
);
}
#[test]
fn the_sidecar_and_input_stash_are_per_tab() {
let mut set = three_tabs();
set.active_mut().input_stash = "half-typed A".into();
set.active_mut().sidecar.turns_this_conversation = 7;
set.active_mut().sidecar.active_roadmap_id = Some("road-a".into());
let _ = set.activate(1).unwrap();
assert_eq!(
set.active().input_stash,
"",
"B starts with its own empty stash"
);
assert_eq!(set.active().sidecar, TabSidecar::default());
set.active_mut().input_stash = "half-typed B".into();
let _ = set.activate(0).unwrap();
assert_eq!(set.active().input_stash, "half-typed A");
assert_eq!(set.active().sidecar.turns_this_conversation, 7);
assert_eq!(
set.active().sidecar.active_roadmap_id.as_deref(),
Some("road-a")
);
}
#[test]
fn activation_is_independent_of_where_it_was_reached_from() {
let snapshot = |set: &TabSet| {
(
set.active().session_id().clone(),
set.active().conversation_id().to_string(),
set.active().input_stash.clone(),
set.active().sidecar.clone(),
)
};
let mut from_a = three_tabs();
let _ = from_a.activate(1).unwrap();
let mut from_c = three_tabs();
let _ = from_c.activate(2).unwrap();
let _ = from_c.activate(1).unwrap();
assert_eq!(
snapshot(&from_a),
snapshot(&from_c),
"A→B and C→B land byte-identical tab-projected state"
);
}
}
#[cfg(test)]
mod ownership_tests {
use super::*;
use newt_core::lifecycle::{self, new_session_id, LifecycleEnvelope, LifecycleEvent};
use std::sync::{Arc, Mutex};
fn collector() -> (lifecycle::Subscription, Arc<Mutex<Vec<LifecycleEnvelope>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&seen);
let sub = lifecycle::subscribe(move |envelope| {
sink.lock().unwrap().push(envelope.clone());
});
(sub, seen)
}
fn attributed_to(seen: &[LifecycleEnvelope], session: &SessionId) -> Vec<LifecycleEvent> {
seen.iter()
.filter(|e| e.session_id.as_deref() == Some(session.as_str()))
.map(|e| e.event.clone())
.collect()
}
#[test]
fn a_to_b_moves_lifecycle_ownership_and_b_to_a_moves_it_back() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
assert_eq!(lifecycle::active_session().as_deref(), Some(b.as_str()));
set.activate(0).unwrap().apply();
assert_eq!(
lifecycle::active_session().as_deref(),
Some(a.as_str()),
"A→B→A returns ownership to A"
);
}
#[test]
fn an_event_after_a_switch_is_attributed_to_the_newly_active_tab() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let (sub, seen) = collector();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
OwnerHandoff {
from: None,
to: a.clone(),
}
.apply();
lifecycle::emit(LifecycleEvent::Thinking);
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
lifecycle::emit(LifecycleEvent::ToolActivity {
tool: "run_command".into(),
});
set.activate(0).unwrap().apply();
lifecycle::emit(LifecycleEvent::TurnCompleted);
drop(sub);
let seen = seen.lock().unwrap();
let to_a = attributed_to(&seen, &a);
let to_b = attributed_to(&seen, &b);
assert!(
to_a.contains(&LifecycleEvent::Thinking),
"the pre-switch Thinking belongs to A: {to_a:?}"
);
assert!(
to_b.contains(&LifecycleEvent::ToolActivity {
tool: "run_command".into()
}),
"tool activity while B was active belongs to B, not A: {to_b:?}"
);
assert!(
!to_a.contains(&LifecycleEvent::ToolActivity {
tool: "run_command".into()
}),
"an INACTIVE tab must not receive the active tab's events: {to_a:?}"
);
assert!(
to_a.contains(&LifecycleEvent::TurnCompleted),
"after switching back, events belong to A again: {to_a:?}"
);
assert!(
!to_b.contains(&LifecycleEvent::TurnCompleted),
"and B, now inactive, receives nothing further: {to_b:?}"
);
}
#[test]
fn a_conversation_switch_inside_a_tab_does_not_change_lifecycle_ownership() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
OwnerHandoff {
from: None,
to: a.clone(),
}
.apply();
set.active_mut().hold_conversation("conv-other");
assert_eq!(
lifecycle::active_session().as_deref(),
Some(a.as_str()),
"resuming another conversation is not a session change"
);
assert_eq!(*set.active().session_id(), a);
}
#[test]
fn closing_a_tab_cannot_end_or_re_anchor_another_live_tab() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
let closed = set.close(0).unwrap();
assert_eq!(closed.tab.conversation_id(), "conv-a");
assert_eq!(
lifecycle::active_session().as_deref(),
Some(b.as_str()),
"B still owns the REPL — closing A cannot end or re-anchor it"
);
assert_eq!(*set.active().session_id(), b);
}
#[test]
fn after_a_close_the_owner_still_names_an_open_tab() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
assert_eq!(lifecycle::active_session().as_deref(), Some(b.as_str()));
set.activate(0).unwrap().apply();
let closed = set.close(1).unwrap();
assert_eq!(closed.tab.conversation_id(), "conv-b");
assert_eq!(
lifecycle::active_session().as_deref(),
Some(a.as_str()),
"there is never an unowned instant"
);
assert!(!set.tabs().iter().any(|t| *t.session_id() == b));
}
#[test]
fn activation_announces_the_tab_so_a_herdr_pane_reports_the_active_one() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let (sub, seen) = collector();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
set.activate(0).unwrap().apply();
drop(sub);
let starts: Vec<String> = seen
.lock()
.unwrap()
.iter()
.filter_map(|e| match &e.event {
LifecycleEvent::SessionStarted { session_id } => Some(session_id.clone()),
_ => None,
})
.collect();
assert_eq!(
starts,
vec![b.to_string(), a.to_string()],
"each activation re-anchors the pane onto the tab now in front"
);
}
#[test]
fn re_activating_the_same_tab_does_not_re_announce() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let (sub, seen) = collector();
let a = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
set.activate(0).unwrap().apply();
drop(sub);
let starts = seen
.lock()
.unwrap()
.iter()
.filter(|e| matches!(e.event, LifecycleEvent::SessionStarted { .. }))
.count();
assert_eq!(starts, 0, "a no-op activation announces nothing");
}
}
#[cfg(test)]
mod single_turn_invariant {
use super::*;
use newt_core::lifecycle::{self, new_session_id, LifecycleEvent};
#[test]
fn there_is_no_supported_tab_switch_during_an_in_flight_turn() {
let _g = newt_core::test_guard::GlobalSettingsGuard::acquire();
let a = new_session_id();
let b = new_session_id();
let mut set = TabSet::new(a.clone(), "conv-a");
let (_i, open) = set.open(b.clone(), "conv-b");
open.apply();
set.activate(0).unwrap().apply();
for event in [
LifecycleEvent::TurnStarted,
LifecycleEvent::Thinking,
LifecycleEvent::ToolActivity {
tool: "run_command".into(),
},
LifecycleEvent::Blocked,
LifecycleEvent::Unblocked,
LifecycleEvent::TurnCompleted,
] {
lifecycle::emit(event);
assert_eq!(
lifecycle::active_session().as_deref(),
Some(a.as_str()),
"ownership cannot move mid-turn — a switch would need &mut to \
state this turn already holds, so it cannot even be built"
);
}
assert_eq!(set.active_index(), 0);
assert_eq!(*set.active().session_id(), a);
}
}