use crate::tabs::{PersonaSelection, TabError, TabSet};
pub(crate) struct TabSwitchCtx<'a> {
pub store: &'a newt_core::ConversationStore,
pub persona_store: &'a crate::PersonaStore,
pub workspace: &'a str,
pub memory: &'a mut newt_core::MemoryManager,
pub system: &'a mut String,
pub active_persona: &'a mut Option<crate::Persona>,
pub active_conversation_id: &'a mut String,
pub compress_state: &'a mut newt_core::CompressState,
pub scratchpad: &'a dyn newt_core::ScratchpadStore,
pub step_ledger: &'a dyn newt_core::StepLedger,
pub active_prompt_context: &'a mut Option<newt_core::TurnPromptContext>,
pub mode_states: &'a crate::ConversationModeStates,
pub baseline: &'a crate::PreferenceBaseline,
pub pending: &'a mut newt_core::PreferenceActions,
pub base_provider: &'a mut Option<String>,
pub base_model: &'a mut Option<String>,
pub cfg: &'a newt_core::ResolvedConfig,
pub choice: &'a mut crate::BackendChoice,
pub inf_url: &'a mut String,
pub inf_model: &'a mut String,
pub inf_kind: &'a mut newt_core::BackendKind,
pub inf_key: &'a mut Option<String>,
pub inf_context_window: &'a mut Option<u32>,
pub turns_this_conversation: &'a mut usize,
pub last_resume_listing: &'a mut Vec<String>,
pub active_roadmap_id: &'a mut Option<String>,
pub interrupted_objective: &'a mut Option<newt_core::TurnPromptContext>,
pub input_stash: &'a mut String,
pub color: bool,
pub verbose: bool,
}
#[derive(Debug, Default)]
pub(crate) struct TransitionOutcome {
pub url_changed: bool,
pub degraded: Option<crate::PinDegraded>,
}
impl TransitionOutcome {
fn from_restore(restored: &crate::PinRestore) -> Self {
Self {
url_changed: restored.url_changed,
degraded: restored.degraded.clone(),
}
}
}
impl TabSwitchCtx<'_> {
fn deactivate(&mut self, tabs: &mut TabSet) {
if let Err(warning) = crate::persist_preference_actions(
Some(self.store),
self.active_conversation_id,
self.pending,
self.base_provider,
self.base_model,
) {
crate::print_newt(&format!("warning: {warning}"), self.color, self.verbose);
}
let materialized = self
.store
.exists(tabs.active().conversation_id())
.unwrap_or(false);
if let Some(seed) = tabs.active_mut().fresh_seed.as_mut() {
if materialized {
} else {
seed.persona = self.active_persona.clone();
seed.pending = std::mem::take(self.pending);
}
}
if materialized {
tabs.active_mut().fresh_seed = None;
}
let outgoing = tabs.active_mut();
outgoing.sidecar.persona_selection = Some(match self.active_persona.as_ref() {
None => PersonaSelection::Absent,
Some(persona) => match persona
.profile
.altitude
.filter(|_| persona.path.as_os_str().is_empty())
{
Some(altitude) => PersonaSelection::SyntheticAltitude(altitude),
None => PersonaSelection::Named(persona.name.clone()),
},
});
outgoing.sidecar.turns_this_conversation = *self.turns_this_conversation as u32;
outgoing.sidecar.last_resume_listing = std::mem::take(self.last_resume_listing);
outgoing.sidecar.active_roadmap_id = self.active_roadmap_id.take();
outgoing.sidecar.interrupted_objective = self.interrupted_objective.take();
outgoing.input_stash = std::mem::take(self.input_stash);
}
fn hydrate_sidecar(&mut self, tabs: &TabSet) {
let incoming = tabs.active();
*self.turns_this_conversation = incoming.sidecar.turns_this_conversation as usize;
*self.last_resume_listing = incoming.sidecar.last_resume_listing.clone();
*self.active_roadmap_id = incoming.sidecar.active_roadmap_id.clone();
*self.interrupted_objective = incoming.sidecar.interrupted_objective.clone();
*self.input_stash = incoming.input_stash.clone();
}
fn reset_and_overlay(&mut self) -> crate::PinRestore {
crate::restore_preference_pin(crate::ConversationPreferenceSwitch {
store: Some(self.store),
conversation_id: self.active_conversation_id,
baseline: self.baseline,
persona: self.active_persona.as_ref(),
pending: self.pending,
base_provider: self.base_provider,
base_model: self.base_model,
cfg: self.cfg,
choice: self.choice,
inf_url: self.inf_url,
inf_model: self.inf_model,
inf_kind: self.inf_kind,
inf_key: self.inf_key,
inf_context_window: self.inf_context_window,
color: self.color,
verbose: self.verbose,
})
}
}
enum PreparedIncoming {
Materialized(Box<crate::PreparedConversationRestore>),
Fresh,
}
fn preflight(
store: &newt_core::ConversationStore,
persona_store: &crate::PersonaStore,
id: &str,
persona_selection: Option<&PersonaSelection>,
) -> Result<PreparedIncoming, TabError> {
#[cfg(test)]
if let Some(reason) = test_seam::forced_failure(id) {
return Err(TabError::PreflightFailed { reason });
}
match store.exists(id) {
Ok(true) => match crate::prepare_conversation_restore(store, persona_store, id) {
Ok(mut prepared) => {
if let Some(selection) = persona_selection {
prepared.persona = match selection {
PersonaSelection::Absent => None,
PersonaSelection::Named(name) => {
Some(persona_store.load(name).map_err(|e| {
TabError::PreflightFailed {
reason: format!(
"its selected persona `{name}` could not be read ({e})"
),
}
})?)
}
PersonaSelection::SyntheticAltitude(altitude) => {
Some(crate::synthetic_altitude_persona(*altitude))
}
};
prepared.persona_warning = None;
}
Ok(PreparedIncoming::Materialized(Box::new(prepared)))
}
Err(e) => Err(TabError::PreflightFailed {
reason: format!("its conversation could not be read ({e})"),
}),
},
Ok(false) => Ok(PreparedIncoming::Fresh),
Err(e) => Err(TabError::PreflightFailed {
reason: format!("the conversation store could not be queried ({e})"),
}),
}
}
impl TabSwitchCtx<'_> {
fn reset_to_clean_conversation(&mut self) {
self.compress_state.reset();
let mut reset = crate::ConversationResetContext {
memory: self.memory,
system: self.system,
conversation_id: self.active_conversation_id,
mode_states: self.mode_states,
};
crate::reset_conversation(self.workspace, self.active_persona.as_ref(), &mut reset);
crate::ConversationScopedState {
scratchpad: self.scratchpad,
step_ledger: self.step_ledger,
active_prompt_context: self.active_prompt_context,
}
.clear();
}
fn commit_incoming(
&mut self,
tabs: &TabSet,
incoming_id: &str,
incoming: PreparedIncoming,
) -> crate::PinRestore {
match incoming {
PreparedIncoming::Materialized(prepared) => {
let mut commit = crate::CommitContext {
workspace: self.workspace,
memory: self.memory,
system: self.system,
active_persona: self.active_persona,
active_conversation_id: self.active_conversation_id,
compress_state: self.compress_state,
scratchpad: self.scratchpad,
step_ledger: self.step_ledger,
active_prompt_context: self.active_prompt_context,
mode_states: self.mode_states,
};
let (_, warning) = crate::commit_conversation_restore(&mut commit, *prepared);
if let Some(w) = warning {
crate::print_newt(&format!("warning: {w}"), self.color, self.verbose);
}
}
PreparedIncoming::Fresh => {
*self.active_conversation_id = incoming_id.to_string();
self.reset_to_clean_conversation();
let seeded = tabs
.active()
.fresh_seed
.as_ref()
.and_then(|s| s.persona.clone());
*self.active_persona = seeded;
crate::seat_persona_dials(self.active_persona.as_ref());
*self.system = crate::rebuild_system_prompt(
self.workspace,
self.memory,
self.active_persona.as_ref(),
self.active_conversation_id,
);
}
}
let restored = self.reset_and_overlay();
if let Some(seed) = tabs.active().fresh_seed.as_ref() {
*self.pending = seed.pending.clone();
}
self.hydrate_sidecar(tabs);
restored
}
}
pub(crate) fn activate_tab(
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
target: usize,
) -> Result<TransitionOutcome, TabError> {
let incoming_tab = tabs
.get(target)
.ok_or(TabError::OutOfRange { open: tabs.len() })?;
let incoming_id = incoming_tab.conversation_id().to_string();
if target == tabs.active_index() {
return Ok(TransitionOutcome {
url_changed: false,
degraded: tabs.active().pin_degraded.clone(),
});
}
let incoming = preflight(
ctx.store,
ctx.persona_store,
&incoming_id,
incoming_tab.sidecar.persona_selection.as_ref(),
)?;
ctx.deactivate(tabs);
let handoff = tabs.activate(target)?;
let restored = ctx.commit_incoming(tabs, &incoming_id, incoming);
tabs.active_mut().pin_degraded = restored.degraded.clone();
handoff.apply();
Ok(TransitionOutcome::from_restore(&restored))
}
pub(crate) fn create_fresh_tab(
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
) -> Result<TransitionOutcome, TabError> {
let fresh = newt_core::new_conversation_id();
match ctx.store.claim(&fresh) {
Ok(newt_core::ClaimOutcome::Claimed) => {}
Ok(newt_core::ClaimOutcome::HeldBy { host, pid }) => {
return Err(TabError::PreflightFailed {
reason: format!("a new conversation id was already held (pid {pid} on {host})"),
})
}
Err(e) => {
return Err(TabError::PreflightFailed {
reason: format!("the new conversation could not be claimed ({e})"),
})
}
}
let inherited_persona = ctx.active_persona.clone();
ctx.deactivate(tabs);
let (_, handoff) = tabs.open(newt_core::lifecycle::new_session_id(), &fresh);
tabs.active_mut().fresh_seed = Some(crate::tabs::FreshSeed {
persona: inherited_persona,
pending: newt_core::PreferenceActions::default(),
});
let restored = ctx.commit_incoming(tabs, &fresh, PreparedIncoming::Fresh);
tabs.active_mut().pin_degraded = restored.degraded.clone();
handoff.apply();
Ok(TransitionOutcome::from_restore(&restored))
}
#[cfg(test)]
mod test_seam {
use std::cell::RefCell;
thread_local! {
static FAILING: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub(super) fn fail_preflight_for(id: &str) {
FAILING.with(|f| *f.borrow_mut() = Some(id.to_string()));
}
pub(super) fn clear() {
FAILING.with(|f| *f.borrow_mut() = None);
}
pub(super) fn forced_failure(id: &str) -> Option<String> {
FAILING.with(|f| {
f.borrow()
.as_deref()
.filter(|failing| *failing == id)
.map(|_| "its conversation could not be read (test seam)".to_string())
})
}
}
pub(crate) fn refusal_text(e: &TabError) -> String {
match e {
TabError::OutOfRange { open } => {
let plural = if *open == 1 { "tab" } else { "tabs" };
format!("no such tab — {open} {plural} open (1..={open})")
}
TabError::LastTab => "can't close the last tab — `:q` is how you leave newt".to_string(),
TabError::PreflightFailed { reason } => format!("staying on this tab — {reason}"),
}
}
#[derive(Debug)]
pub(crate) enum Adopted {
ActivatedExistingTab {
index: usize,
outcome: TransitionOutcome,
},
AlreadyHere,
ProceedInActiveTab,
}
pub(crate) fn adopt_conversation(
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
target: &str,
) -> Result<Adopted, TabError> {
if let Some(index) = tabs.find_by_conversation(target) {
if index == tabs.active_index() {
return Ok(Adopted::AlreadyHere);
}
let outcome = activate_tab(ctx, tabs, index)?;
return Ok(Adopted::ActivatedExistingTab { index, outcome });
}
if *ctx.active_conversation_id == target {
return Ok(Adopted::AlreadyHere);
}
Ok(Adopted::ProceedInActiveTab)
}
pub(crate) fn exit_release_all(ctx: &mut TabSwitchCtx<'_>, tabs: &mut TabSet) -> Vec<String> {
ctx.deactivate(tabs);
let held: Vec<String> = tabs
.claimed_conversations()
.into_iter()
.map(str::to_string)
.collect();
for id in &held {
if let Err(e) = ctx.store.release(id) {
crate::print_newt(
&format!("warning: releasing `{id}` on exit failed: {e}"),
ctx.color,
ctx.verbose,
);
}
}
held
}
pub(crate) fn tab_surface_refusal(
surface_is_rich: bool,
ephemeral: bool,
has_store: bool,
) -> Option<&'static str> {
if !surface_is_rich {
return Some(
"tabs are a rich-TUI feature; this session is single-conversation — \
use /resume, /new and /rename",
);
}
if ephemeral {
return Some(
"tabs need conversation persistence; this session is ephemeral and \
leaves no trace by design",
);
}
if !has_store {
return Some("tabs need conversation persistence; this session has no store");
}
None
}
pub(crate) fn degraded_turn_refusal(degraded: Option<&crate::PinDegraded>) -> Option<String> {
degraded.map(|d| {
format!(
"{} — this tab's pinned posture is not in force, so the prompt was not accepted. \
Fix what the pin names (usually a [[backends]] entry) then `/tab retry`, or \
`/psyche` to repin.",
d.summary()
)
})
}
#[derive(Debug)]
pub(crate) struct ClosedTab {
pub tab: crate::tabs::TabState,
pub outcome: TransitionOutcome,
}
pub(crate) fn close_tab(
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
target: usize,
) -> Result<ClosedTab, TabError> {
if target >= tabs.len() {
return Err(TabError::OutOfRange { open: tabs.len() });
}
if tabs.len() == 1 {
return Err(TabError::LastTab);
}
let mut outcome = TransitionOutcome::default();
if target == tabs.active_index() {
let neighbor = if target + 1 < tabs.len() {
target + 1
} else {
target - 1
};
outcome = activate_tab(ctx, tabs, neighbor)?;
}
let closed = tabs.close(target)?;
if let Err(e) = ctx.store.release(closed.tab.conversation_id()) {
crate::print_newt(
&format!("warning: releasing the closed tab's claim failed: {e}"),
ctx.color,
ctx.verbose,
);
}
Ok(ClosedTab {
tab: closed.tab,
outcome,
})
}
pub(crate) fn handle_tab_action(
action: crate::tabs::TabAction,
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
) -> TransitionOutcome {
use crate::tabs::TabAction;
let color = ctx.color;
let verbose = ctx.verbose;
let refuse = |e: TabError, tabs: &TabSet| match e {
TabError::OutOfRange { open } => {
let plural = if open == 1 { "tab" } else { "tabs" };
crate::print_newt(
&format!("no such tab — {open} {plural} open (1..={open})"),
color,
verbose,
);
}
TabError::LastTab => {
let _ = tabs;
crate::print_newt(
"can't close the last tab — `:q` is how you leave newt",
color,
verbose,
);
}
TabError::PreflightFailed { reason } => {
crate::print_newt(&format!("staying on this tab — {reason}"), color, verbose);
}
};
match action {
TabAction::List => {
list_tabs(ctx, tabs);
TransitionOutcome::default()
}
TabAction::New => match create_fresh_tab(ctx, tabs) {
Ok(outcome) => {
crate::print_newt(
&format!("tab {} — new conversation", tabs.active_index() + 1),
color,
verbose,
);
outcome
}
Err(e) => {
refuse(e, tabs);
TransitionOutcome::default()
}
},
TabAction::Retry => {
let restored = ctx.reset_and_overlay();
tabs.active_mut().pin_degraded = restored.degraded.clone();
match restored.degraded.as_ref() {
None => crate::print_newt(
"pin applied — this tab is no longer degraded",
color,
verbose,
),
Some(d) => crate::print_newt(
&format!("{} — still not in force", d.summary()),
color,
verbose,
),
}
TransitionOutcome::from_restore(&restored)
}
TabAction::Next => switch_to(ctx, tabs, (tabs.active_index() + 1) % tabs.len(), refuse),
TabAction::Prev(n) => {
let len = tabs.len();
let back = n % len;
switch_to(ctx, tabs, (tabs.active_index() + len - back) % len, refuse)
}
TabAction::Goto(index) => switch_to(ctx, tabs, index, refuse),
TabAction::Close(which) => {
let target = which.unwrap_or_else(|| tabs.active_index());
match close_tab(ctx, tabs, target) {
Ok(closed) => {
crate::print_newt(
&format!(
"closed tab — `{}` stays open, resume it with /resume",
closed.tab.conversation_id()
),
color,
verbose,
);
report_degraded(ctx, tabs);
closed.outcome
}
Err(e) => {
refuse(e, tabs);
TransitionOutcome::default()
}
}
}
TabAction::Move { from, to } => {
match tabs.move_tab(from, to) {
Ok(()) => list_tabs(ctx, tabs),
Err(e) => refuse(e, tabs),
}
TransitionOutcome::default()
}
TabAction::Rename { index, title } => {
let target = index.unwrap_or_else(|| tabs.active_index());
match tabs.get(target).map(|t| t.conversation_id().to_string()) {
Some(id) => {
let persona = tabs
.get(target)
.and_then(|t| t.fresh_seed.as_ref())
.map(|seed| seed.persona.as_ref().map(|p| p.name.clone()))
.unwrap_or_else(|| {
if target == tabs.active_index() {
ctx.active_persona.as_ref().map(|p| p.name.clone())
} else {
None
}
});
match crate::rename_conversation(ctx.store, &id, &title, persona.as_deref()) {
Ok(()) => {
if let Some(t) = tabs.tabs_mut().get_mut(target) {
t.fresh_seed = None;
}
crate::print_newt(
&format!("tab {} renamed to '{title}'", target + 1),
color,
verbose,
);
}
Err(e) => crate::print_newt(&format!("rename failed: {e}"), color, verbose),
}
TransitionOutcome::default()
}
None => {
refuse(TabError::OutOfRange { open: tabs.len() }, tabs);
TransitionOutcome::default()
}
}
}
}
}
fn switch_to(
ctx: &mut TabSwitchCtx<'_>,
tabs: &mut TabSet,
target: usize,
refuse: impl Fn(TabError, &TabSet),
) -> TransitionOutcome {
match activate_tab(ctx, tabs, target) {
Ok(switched) => {
let marker = match &switched.degraded {
Some(d) => format!(" [{}]", d.summary()),
None => String::new(),
};
crate::print_newt(
&format!(
"tab {} — {}{marker}",
tabs.active_index() + 1,
crate::tab_label(ctx.store, tabs.active().conversation_id())
),
ctx.color,
ctx.verbose,
);
switched
}
Err(e) => {
refuse(e, tabs);
TransitionOutcome::default()
}
}
}
pub(crate) fn report_degraded(ctx: &TabSwitchCtx<'_>, tabs: &TabSet) {
if let Some(d) = tabs.active().pin_degraded.as_ref() {
crate::print_newt(
&format!(
"{} — turns are refused on this tab until the pin is in force (`/tab retry`)",
d.summary()
),
ctx.color,
ctx.verbose,
);
}
}
fn list_tabs(ctx: &TabSwitchCtx<'_>, tabs: &TabSet) {
crate::print_newt("open tabs:", ctx.color, ctx.verbose);
for (i, tab) in tabs.tabs().iter().enumerate() {
let marker = if i == tabs.active_index() { "*" } else { " " };
let identity = if ctx.verbose {
format!(" [{}]", tab.session_id())
} else {
String::new()
};
let degraded = match tab.pin_degraded.as_ref() {
Some(d) => format!(" {}", d.summary()),
None => String::new(),
};
crate::print_newt(
&format!(
"{marker} {}. {}{degraded}{identity}",
i + 1,
crate::tab_label(ctx.store, tab.conversation_id())
),
ctx.color,
ctx.verbose,
);
}
}
#[cfg(test)]
#[path = "tab_switch_tests/state_machine_tests.rs"]
mod state_machine_tests;