use crate::{
commands::CommandRegistry,
config::EffectiveConfig,
output::{OutputMode, output_mode},
sessions::{Session, SessionManager},
shell::{ShellState, run_repl, run_tty_repl},
};
use anyhow::Result;
use std::io::{self, IsTerminal, Write};
pub(super) fn run_interactive(
config: EffectiveConfig,
instructions: Vec<crate::instructions::InstructionFile>,
skills: crate::skills::SkillDiscovery,
commands: CommandRegistry,
manager: SessionManager,
active_session: Option<Session>,
cwd: std::path::PathBuf,
) -> Result<()> {
let stdin = io::stdin();
let stdin_is_tty = stdin.is_terminal();
let stdout = io::stdout();
let stdout_is_tty = stdout.is_terminal();
let use_line_editor = stdin_is_tty && stdout_is_tty;
let mode = output_mode(stdout_is_tty, !config.no_color, stdout_is_tty);
let model = config
.model
.clone()
.unwrap_or_else(|| crate::providers::DEFAULT_CODEX_MODEL.to_string());
let mut state = ShellState::new(
manager,
active_session,
cwd.clone(),
model,
config.auth_state(),
)
.with_config(config.clone());
let mut prompt_context = InteractivePromptContext {
fallback_config: &config,
instructions: &instructions,
skills: &skills,
mode,
rich_header_rendered: false,
};
if use_line_editor {
let mut writer = stdout;
run_tty_repl(
&mut writer,
&commands,
&mut state,
|state, prompt, writer| prompt_context.run(state, prompt, writer),
)
} else {
let mut reader = stdin.lock();
let mut writer = stdout.lock();
run_repl(
&mut reader,
&mut writer,
&commands,
&mut state,
|state, prompt, writer| prompt_context.run(state, prompt, writer),
)
}
}
struct InteractivePromptContext<'a> {
fallback_config: &'a EffectiveConfig,
instructions: &'a [crate::instructions::InstructionFile],
skills: &'a crate::skills::SkillDiscovery,
mode: OutputMode,
rich_header_rendered: bool,
}
impl InteractivePromptContext<'_> {
fn run<W: Write>(
&mut self,
state: &mut ShellState,
prompt: &str,
writer: &mut W,
) -> Result<String> {
let mut config = state
.config
.clone()
.unwrap_or_else(|| self.fallback_config.clone());
config.model = Some(state.model.clone());
let mut sink = super::CliOutputSink::with_mode(writer, self.mode)
.suppress_session_header(self.rich_header_rendered);
let is_bash_mode = crate::bash_mode::parse_bash_mode_prompt(prompt).is_some();
let result = if is_bash_mode {
crate::agent::runner::run_bash_mode_once(
&config,
self.skills,
crate::agent::runner::ProviderRunOptions {
settings: None,
prompt,
session: state.current_session.as_ref(),
cwd: &state.cwd,
output_sink: Some(&mut sink),
selected_primary_agent: None,
cancellation: None,
session_title_notifier: None,
invocation_mode: crate::output::InvocationMode::Shell,
disabled_tools: None,
disabled_subagent_profiles: None,
subagent_profile_discovery: None,
mcp: None,
},
)
} else {
if !state.auth_state.is_ready() {
let message = format!(
"provider not configured for '{}'; run /login openai-codex or configure a custom provider with /login custom-provider",
state.auth_state.provider()
);
super::diagnostics::record_error(
state.current_session.as_ref(),
&state.cwd,
&message,
)?;
return Ok(message);
}
crate::agent::runner::run_provider_once_streaming(
&config,
self.instructions,
self.skills,
crate::agent::runner::ProviderRunOptions {
settings: None,
prompt,
session: state.current_session.as_ref(),
cwd: &state.cwd,
output_sink: Some(&mut sink),
selected_primary_agent: None,
cancellation: None,
session_title_notifier: None,
invocation_mode: crate::output::InvocationMode::Shell,
disabled_tools: None,
disabled_subagent_profiles: None,
subagent_profile_discovery: None,
mcp: None,
},
)
};
sink.finish_line()?;
if matches!(self.mode, OutputMode::Rich(_)) {
self.rich_header_rendered = true;
}
match result {
Ok(_) => Ok(String::new()),
Err(error) => {
if !is_bash_mode {
super::diagnostics::record_error(
state.current_session.as_ref(),
&state.cwd,
&error.to_string(),
)?;
}
Ok(error.to_string())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::{AuthState, McPaths},
providers::OPENAI_CODEX_PROVIDER,
};
#[test]
fn interactive_missing_auth_returns_actionable_message_and_persists_diagnostic() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let config = EffectiveConfig {
provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
model: Some("model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: McPaths::from_root(temp.path().join("mc")),
};
let skills = crate::skills::SkillDiscovery::default();
let mut state = ShellState::new(
manager,
Some(session.clone()),
temp.path().to_path_buf(),
"model".to_string(),
AuthState::Missing {
provider: OPENAI_CODEX_PROVIDER.to_string(),
},
)
.with_config(config.clone());
let mut context = InteractivePromptContext {
fallback_config: &config,
instructions: &[],
skills: &skills,
mode: OutputMode::Plain,
rich_header_rendered: false,
};
let mut output = Vec::new();
let message = context.run(&mut state, "hello", &mut output).unwrap();
assert!(message.contains("provider not configured for 'openai-codex'"));
assert!(message.contains("run /login openai-codex"));
assert!(output.is_empty());
let events = session.read_events().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "diagnostic");
assert_eq!(events[0].payload["level"], "error");
assert_eq!(events[0].payload["message"], message);
}
#[test]
fn interactive_bash_mode_bypasses_missing_auth_and_does_not_persist() {
let temp = tempfile::TempDir::new().unwrap();
let manager = SessionManager::new(temp.path().join("sessions"));
let session = manager.create().unwrap();
let config = EffectiveConfig {
provider: Some(OPENAI_CODEX_PROVIDER.to_string()),
model: Some("model".to_string()),
no_color: false,
file_autocomplete_respects_gitignore: true,
custom_providers: std::collections::BTreeMap::new(),
thinking_level: crate::thinking::ThinkingLevel::Default,
api_key: None,
auth: None,
paths: McPaths::from_root(temp.path().join("mc")),
};
let skills = crate::skills::SkillDiscovery::default();
let mut state = ShellState::new(
manager,
Some(session.clone()),
temp.path().to_path_buf(),
"model".to_string(),
AuthState::Missing {
provider: OPENAI_CODEX_PROVIDER.to_string(),
},
)
.with_config(config.clone());
let mut context = InteractivePromptContext {
fallback_config: &config,
instructions: &[],
skills: &skills,
mode: OutputMode::Plain,
rich_header_rendered: false,
};
let mut output = Vec::new();
let message = context.run(&mut state, "!printf hi", &mut output).unwrap();
assert_eq!(message, "");
let rendered = String::from_utf8(output).unwrap();
assert!(rendered.contains("hi"), "{rendered}");
let events = session.read_events().unwrap();
assert!(events.is_empty(), "events: {events:?}");
}
}