use std::sync::Arc;
#[path = "testing/tape.rs"]
pub mod tape;
#[path = "testing/scripted.rs"]
pub mod scripted;
#[path = "testing/fake_daemon.rs"]
pub mod fake_daemon;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use ratatui::backend::{CrosstermBackend, TestBackend};
use ratatui::buffer::Buffer;
use ratatui::layout::{Rect, Size};
use ratatui::{Terminal, TerminalOptions, Viewport};
use synaps_cli::skills::keybinds::KeybindRegistry;
use synaps_cli::skills::registry::CommandRegistry;
use synaps_cli::skills::BUILTIN_COMMANDS;
use synaps_cli::{Runtime, Session};
use super::app::{App, ChatMessage};
use super::session_link::{PromptBridge, SessionLink};
use agent_engine::session::SessionEventWire;
use super::draw::{build_render_model, render_frame_into};
use super::input::{self, InputAction};
pub struct TestHarness {
app: App,
link: SessionLink,
prompt_bridge: PromptBridge,
scripted_log: std::sync::Arc<std::sync::Mutex<scripted::ScriptedLog>>,
scripted_history: std::sync::Arc<std::sync::Mutex<Vec<synaps_cli::SharedMessage>>>,
secret_prompt_rx: std::sync::Arc<
std::sync::Mutex<
tokio::sync::mpsc::UnboundedReceiver<synaps_cli::tools::SecretPromptRequest>,
>,
>,
registry: Arc<CommandRegistry>,
keybinds: KeybindRegistry,
terminal: Terminal<TestBackend>,
size: Size,
actions: Vec<String>,
pending_slash: Vec<(String, String)>,
pending_streaming: Vec<String>,
quit_requested: bool,
fake_daemon: Option<fake_daemon::FakeDaemon>,
}
pub const SLASH_DRIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
fn block_on_bounded<F: std::future::Future>(what: &str, fut: F) -> F::Output {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread tokio runtime construction is infallible");
rt.block_on(async move {
match tokio::time::timeout(SLASH_DRIVE_TIMEOUT, fut).await {
Ok(out) => out,
Err(_) => panic!(
"bounded async drive timed out after {SLASH_DRIVE_TIMEOUT:?}: {what} — \
this command is not in the sync-safe subset; keep it P4-style recorded"
),
}
})
}
pub fn sessions_test_now() -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp(1_800_000_000, 0).unwrap_or_default()
}
impl Drop for TestHarness {
fn drop(&mut self) {
super::header::set_build_id_for_thread(None);
super::theme::set_terminal_default_bg_for_thread(None);
}
}
impl TestHarness {
pub fn boot() -> Self {
Self::boot_with_size(80, 24)
}
pub fn boot_with_size(cols: u16, rows: u16) -> Self {
let session = Session::new(synaps_cli::models::default_model(), "medium", None);
let mut app = App::new_with_clock(session, super::clock::TuiClock::test());
app.agent_name = "agent".to_string();
app.session.id = "test-session".to_string();
app.header_folder = "~/Projects/SynapsCLI".to_string();
app.logo_build_t = None;
let backend = TestBackend::new(cols, rows);
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)
.expect("TestBackend terminal construction is infallible");
let transport = scripted::ScriptedTransport::new(Runtime::new_headless());
let scripted_log = std::sync::Arc::clone(&transport.log);
let scripted_history = transport.history();
let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
TestHarness {
app,
link: SessionLink::new(Box::new(transport)),
prompt_bridge: PromptBridge::new(secret_prompt_tx),
scripted_log,
scripted_history,
secret_prompt_rx: std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx)),
registry: Arc::new(CommandRegistry::new(BUILTIN_COMMANDS, Vec::new())),
keybinds: KeybindRegistry::new(),
terminal,
size: Size::new(cols, rows),
actions: Vec::new(),
pending_slash: Vec::new(),
pending_streaming: Vec::new(),
quit_requested: false,
fake_daemon: None,
}
}
pub fn key(&mut self, code: KeyCode, mods: KeyModifiers) -> &mut Self {
self.event(Event::Key(KeyEvent::new(code, mods)))
}
pub fn type_str(&mut self, text: &str) -> &mut Self {
for ch in text.chars() {
self.key(KeyCode::Char(ch), KeyModifiers::empty());
}
self
}
pub fn paste(&mut self, text: &str) -> &mut Self {
self.event(Event::Paste(text.to_string()))
}
pub fn mouse(&mut self, event: MouseEvent) -> &mut Self {
self.event(Event::Mouse(event))
}
pub fn resize(&mut self, cols: u16, rows: u16) -> &mut Self {
self.size = Size::new(cols, rows);
self.terminal.backend_mut().resize(cols, rows);
self.event(Event::Resize(cols, rows))
}
pub fn event(&mut self, event: Event) -> &mut Self {
let streaming = self.app.streaming;
let action = input::handle_event(
event,
&mut self.app,
&**self.link.view(),
streaming,
&self.registry,
&self.keybinds,
3,
);
self.record(action);
self.drive_session_switch();
self
}
pub fn use_fake_daemon(&mut self) -> fake_daemon::FakeDaemon {
let d = fake_daemon::FakeDaemon::default();
self.fake_daemon = Some(d.clone());
d
}
pub fn sessions_select(&mut self, id: &str) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
st.selected = Some(id.to_string());
}
self
}
pub fn subagent_count(&self) -> usize {
self.app.subagents.len()
}
pub fn queued_message(&self) -> Option<String> {
self.app.queued_message.clone()
}
pub fn session_name(&self) -> Option<String> {
self.app.session.name.clone()
}
pub fn request_continue(&mut self, journal_id: &str) -> &mut Self {
self.request_continue_in(journal_id, None)
}
pub fn request_continue_in(
&mut self,
journal_id: &str,
cwd: Option<std::path::PathBuf>,
) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
st.pending_switch = Some(super::sessions::SwitchTarget::Continue {
journal_id: journal_id.to_string(),
cwd,
});
}
self.drive_session_switch()
}
pub fn header_folder(&self) -> String {
self.app.header_folder.clone()
}
pub fn drive_session_switch(&mut self) -> &mut Self {
let Some(target) = self.app.sessions.as_mut().and_then(|s| s.pending_switch.take()) else {
return self;
};
let render_handle = super::render_thread::RenderHandle::headless();
let daemon = self.fake_daemon.clone();
block_on_bounded(
"sessions::switch::perform",
super::sessions::switch::perform(
&mut self.app,
&mut self.link,
&mut self.prompt_bridge,
target,
daemon.as_ref().map(|d| d as &dyn super::sessions::switch::Connector),
&self.registry,
&render_handle,
),
);
let id = self.link.transport().session_id().0.clone();
if let Some(log) = daemon.as_ref().and_then(|d| d.log_of(&id)) {
self.scripted_log = log;
}
self
}
pub fn set_terminal_bg(&mut self, bg: Option<(u8, u8, u8)>) -> &mut Self {
super::theme::set_terminal_default_bg_for_thread(Some(bg));
self
}
pub fn render(&mut self) -> &Buffer {
let (model, patch) = build_render_model(
&mut super::view_model::ViewInputs::from_app(&mut self.app),
&**self.link.view(),
&self.registry,
self.size,
)
.expect("build_render_model returned None — gamba never runs headless");
patch.apply(&mut self.app);
let (mut boot_fx, mut exit_fx) = (None, None);
self.terminal
.draw(|frame| {
render_frame_into(
frame,
&model,
&mut boot_fx,
&mut exit_fx,
std::time::Duration::ZERO,
)
})
.expect("TestBackend draw is infallible");
self.terminal.backend().buffer()
}
pub fn render_with_boot_fx(
&mut self,
boot_fx: &mut Option<tachyonfx::Effect>,
elapsed: std::time::Duration,
) {
let (model, patch) = build_render_model(
&mut super::view_model::ViewInputs::from_app(&mut self.app),
&**self.link.view(),
&self.registry,
self.size,
)
.expect("build_render_model returned None — gamba never runs headless");
patch.apply(&mut self.app);
let mut exit_fx = None;
self.terminal
.draw(|frame| render_frame_into(frame, &model, boot_fx, &mut exit_fx, elapsed))
.expect("TestBackend draw is infallible");
}
pub fn render_ansi(&mut self) -> Vec<u8> {
let (model, patch) = build_render_model(
&mut super::view_model::ViewInputs::from_app(&mut self.app),
&**self.link.view(),
&self.registry,
self.size,
)
.expect("build_render_model returned None — gamba never runs headless");
patch.apply(&mut self.app);
let sink = SharedSink::default();
let area = Rect::new(0, 0, self.size.width, self.size.height);
let mut terminal = Terminal::with_options(
CrosstermBackend::new(sink.clone()),
TerminalOptions {
viewport: Viewport::Fixed(area),
},
)
.expect("in-memory CrosstermBackend terminal construction is infallible");
let (mut boot_fx, mut exit_fx) = (None, None);
terminal
.draw(|frame| {
render_frame_into(
frame,
&model,
&mut boot_fx,
&mut exit_fx,
std::time::Duration::ZERO,
)
})
.expect("draw to an in-memory sink is infallible");
sink.take()
}
pub fn snapshot(&mut self) -> String {
let buf = self.render();
let area = *buf.area();
let mut out = String::with_capacity((area.width as usize + 1) * area.height as usize);
for y in area.top()..area.bottom() {
let mut line = String::with_capacity(area.width as usize);
for x in area.left()..area.right() {
line.push_str(buf[(x, y)].symbol());
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
pub fn input_contents(&self) -> String {
self.app.input_text()
}
pub fn prompt_animating(&self) -> bool {
self.app
.prompt_clock
.animating(self.app.clock.now(), self.app.streaming)
}
pub fn quit_requested(&self) -> bool {
self.quit_requested
}
pub fn take_actions(&mut self) -> Vec<String> {
std::mem::take(&mut self.actions)
}
pub fn push_system_message(&mut self, text: &str) -> &mut Self {
self.app.push_msg(ChatMessage::System(text.to_string()));
self
}
pub fn push_user_message(&mut self, text: &str) -> &mut Self {
self.app.push_msg(ChatMessage::User(text.to_string()));
self
}
pub fn push_thinking_message(&mut self, text: &str) -> &mut Self {
self.app.push_msg(ChatMessage::Thinking(text.to_string()));
self
}
pub fn push_text_message(&mut self, text: &str) -> &mut Self {
self.app.push_msg(ChatMessage::Text(text.to_string()));
self
}
pub fn selected_text(&mut self) -> Option<String> {
self.app.transcript.selected_text()
}
pub fn open_settings_modal(&mut self) -> &mut Self {
self.app.settings = Some(super::settings::SettingsState::new());
self.app.modal_stack.push(super::focus::PaneId::Settings);
self
}
pub fn open_sessions_with(&mut self, socket: Option<std::path::PathBuf>) -> &mut Self {
let own = super::sessions::own_row(&self.app, &self.link);
super::sessions::open_with(&mut self.app, own, socket);
if let Some(st) = self.app.sessions.as_mut() {
st.now = sessions_test_now();
st.home = Some(std::path::PathBuf::from("/home/u"));
st.past.clock = Some(agent_core::session_catalog::Clock::fixed(
sessions_test_now(),
chrono::FixedOffset::east_opt(0).expect("utc"),
));
}
self
}
pub fn own_session_id(&self) -> String {
self.link.transport().session_id().0.clone()
}
pub fn sessions_feed_live(
&mut self,
result: Result<Vec<agent_engine::session::SessionMeta>, String>,
) -> &mut Self {
let own = super::sessions::own_row(&self.app, &self.link);
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
st.apply(super::sessions::SessionsMsg::Live { gen, result }, || own);
st.now = sessions_test_now();
}
self
}
pub fn sessions_feed_pins(&mut self, pins: Vec<agent_engine::daemon::pins::PinEntry>) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
st.apply(super::sessions::SessionsMsg::Pins { gen, pins }, || unreachable!());
}
self
}
pub fn sessions_feed_catalog_dir(&mut self, dir: &std::path::Path, index: &std::path::Path) -> &mut Self {
use agent_core::session_catalog::{load_catalog, CatalogPaths, PreviewCache};
let paths = CatalogPaths {
sessions_dir: dir.to_path_buf(),
index_path: index.to_path_buf(),
cache_path: dir.with_file_name("catalog-test-previews.json"),
};
let mut cat = load_catalog(&paths).expect("fixture catalog");
let mut cache = PreviewCache::default();
let no = std::sync::atomic::AtomicBool::new(false);
cache.refresh(dir, &cat.entries, &no, &mut |_, _| {});
cat.apply_previews(&cache);
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
let msg = super::sessions::past::PastMsg::Catalog(Ok(std::sync::Arc::new(cat)));
st.apply(super::sessions::SessionsMsg::Past { gen, msg }, || unreachable!());
}
self
}
pub fn sessions_past_rows(&self) -> Vec<String> {
let Some(st) = self.app.sessions.as_ref() else { return Vec::new() };
let p = &st.past;
match &p.deep {
Some(d) => d.hits.iter().map(|h| h.id.clone()).collect(),
None => p.derived.view.rows.iter().map(|&i| p.entries[i].id.clone()).collect(),
}
}
pub fn sessions_pending_continue_cwd(&self) -> Option<Option<std::path::PathBuf>> {
match self.app.sessions.as_ref()?.pending_switch.as_ref()? {
super::sessions::SwitchTarget::Continue { cwd, .. } => Some(cwd.clone()),
super::sessions::SwitchTarget::Attach { .. } => None,
}
}
pub fn sessions_pending_continue(&self) -> Option<String> {
match self.app.sessions.as_ref()?.pending_switch.as_ref()? {
super::sessions::SwitchTarget::Continue { journal_id, .. } => Some(journal_id.clone()),
super::sessions::SwitchTarget::Attach { .. } => None,
}
}
pub fn sessions_feed_rename(&mut self, result: Result<(), String>) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
let Some(r) = st.past.rename.as_ref() else { return self };
let (id, name) = (r.id.clone(), Some(r.buffer.trim().to_string()).filter(|n| !n.is_empty()));
let msg = super::sessions::past::PastMsg::Renamed { id, name, result };
st.apply(super::sessions::SessionsMsg::Past { gen, msg }, || unreachable!());
}
self
}
pub fn sessions_rename_state(&self) -> Option<(String, Option<String>)> {
let r = self.app.sessions.as_ref()?.past.rename.as_ref()?;
Some((r.buffer.clone(), r.error.clone()))
}
pub fn sessions_feed_op(&mut self, result: Result<String, String>) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
let op = st.pending.clone().unwrap_or(super::sessions::SessionsOp::Pin { id: String::new(), on: true });
st.apply(super::sessions::SessionsMsg::OpDone { gen, op, result }, || unreachable!());
}
self
}
pub fn sessions_feed_op_for(&mut self, kind: &str, id: &str, result: Result<String, String>) -> &mut Self {
use super::sessions::SessionsOp;
let id = id.to_string();
let op = match kind {
"pin" => SessionsOp::Pin { id, on: true },
"unpin" => SessionsOp::Pin { id, on: false },
"clear-pin" => SessionsOp::ClearPin { id },
"pin-past" => SessionsOp::PinPast { journal_id: id, cwd: None },
other => panic!("unknown op kind {other}"),
};
if let Some(st) = self.app.sessions.as_mut() {
let gen = st.gen;
st.apply(super::sessions::SessionsMsg::OpDone { gen, op, result }, || unreachable!());
}
self
}
pub fn sessions_mark_pending(&mut self, op_close_id: &str) -> &mut Self {
if let Some(st) = self.app.sessions.as_mut() {
let label = st
.live
.iter()
.find(|m| m.id.0 == op_close_id)
.map(super::sessions::label)
.unwrap_or_else(|| op_close_id.to_string());
st.pending = Some(super::sessions::SessionsOp::Close { id: op_close_id.to_string(), label });
}
self
}
pub fn sessions_open(&self) -> bool {
self.app.sessions.is_some()
}
pub fn sessions_confirm_open(&self) -> bool {
self.app.sessions.as_ref().is_some_and(|s| s.confirm.is_some())
}
pub fn sessions_notice(&self) -> Option<String> {
self.app.sessions.as_ref().and_then(|s| s.notice.as_ref().map(|n| n.text.clone()))
}
pub fn open_models_modal(&mut self) -> &mut Self {
self.app.models = Some(super::models::ModelsModalState::new());
self.app.modal_stack.push(super::focus::PaneId::Models);
self
}
pub fn open_plugins_modal(&mut self) -> &mut Self {
self.app.plugins = Some(super::plugins::PluginsModalState::new(
synaps_cli::skills::state::PluginsState::default(),
));
self.app.modal_stack.push(super::focus::PaneId::Plugins);
self
}
pub fn activate_secret_prompt(&mut self, title: &str, prompt: &str) -> &mut Self {
let _rx = self.activate_prompt_with_kind(
synaps_cli::tools::PromptKind::Secret,
title,
prompt,
);
self
}
pub fn activate_prompt_with_kind(
&mut self,
kind: synaps_cli::tools::PromptKind,
title: &str,
prompt: &str,
) -> tokio::sync::oneshot::Receiver<Option<String>> {
self.activate_prompt_scoped(kind, title, prompt, None)
}
pub fn activate_tool_call_confirm(
&mut self,
extension: &str,
tool: &str,
prompt: &str,
) -> tokio::sync::oneshot::Receiver<Option<String>> {
self.activate_prompt_scoped(
synaps_cli::tools::PromptKind::Confirm,
agent_engine::session::CONFIRM_PROMPT_TITLE,
prompt,
Some(agent_engine::session::ConfirmScope {
extensions: vec![extension.to_string()],
tool: Some(tool.to_string()),
rewritten_by: vec![],
}),
)
}
fn activate_prompt_scoped(
&mut self,
kind: synaps_cli::tools::PromptKind,
title: &str,
prompt: &str,
confirm_scope: Option<agent_engine::session::ConfirmScope>,
) -> tokio::sync::oneshot::Receiver<Option<String>> {
use synaps_cli::tools::SecretPromptRequest;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
tx.send(SecretPromptRequest {
kind,
title: title.to_string(),
prompt: prompt.to_string(),
confirm_scope,
response_tx,
})
.expect("prompt request send is infallible on a fresh channel");
let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
self.app.secret_prompts.poll_requests(&rx);
input::reconcile_secret_prompt(&mut self.app);
self.app.secret_prompts.arm_now_for_tests();
response_rx
}
pub fn activate_tool_call_confirm_unarmed(
&mut self,
extension: &str,
tool: &str,
prompt: &str,
) -> tokio::sync::oneshot::Receiver<Option<String>> {
use synaps_cli::tools::SecretPromptRequest;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
tx.send(SecretPromptRequest {
kind: synaps_cli::tools::PromptKind::Confirm,
title: agent_engine::session::CONFIRM_PROMPT_TITLE.to_string(),
prompt: prompt.to_string(),
confirm_scope: Some(agent_engine::session::ConfirmScope {
extensions: vec![extension.to_string()],
tool: Some(tool.to_string()),
rewritten_by: vec![],
}),
response_tx,
})
.expect("prompt request send is infallible on a fresh channel");
let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
self.app.secret_prompts.poll_requests(&rx);
input::reconcile_secret_prompt(&mut self.app);
response_rx
}
pub fn arm_confirm_now(&mut self) {
self.app.secret_prompts.arm_now_for_tests();
}
pub fn age_confirm_guard(&mut self, ms: u64) {
self.app
.secret_prompts
.age_input_guard_for_tests(std::time::Duration::from_millis(ms));
}
pub fn activate_confirm_prompt(
&mut self,
title: &str,
prompt: &str,
) -> tokio::sync::oneshot::Receiver<Option<String>> {
self.activate_prompt_with_kind(synaps_cli::tools::PromptKind::Confirm, title, prompt)
}
pub fn confirm_focus(&self) -> synaps_cli::tools::ConfirmChoice {
self.app.secret_prompts.confirm_focus()
}
pub fn secret_prompt_active(&self) -> bool {
self.app.secret_prompts.is_active()
}
pub fn modal_stack_depth(&self) -> usize {
self.app.modal_stack.depth()
}
pub fn top_pane_name(&self) -> &'static str {
use super::focus::PaneId;
match self.app.modal_stack.top() {
PaneId::Chat => "chat",
PaneId::HelpFind => "help-find",
PaneId::Effort => "effort",
PaneId::Sessions => "sessions",
PaneId::Models => "models",
PaneId::Plugins => "plugins",
PaneId::Settings => "settings",
PaneId::PluginEditor => "plugin-editor",
PaneId::SecretPrompt => "secret-prompt",
}
}
pub fn settings_focus_side(&self) -> Option<&'static str> {
self.app.settings.as_ref().map(|st| match st.focus {
super::settings::Focus::Left => "left",
super::settings::Focus::Right => "right",
})
}
pub fn plugins_focus_side(&self) -> Option<&'static str> {
self.app.plugins.as_ref().map(|st| match st.focus {
super::plugins::state::Focus::Left => "left",
super::plugins::state::Focus::Right => "right",
})
}
pub fn open_plugin_editor(&mut self) -> &mut Self {
use super::settings::plugin_editor::PluginEditorSession;
use synaps_cli::extensions::settings_editor::SettingsEditorRenderParams;
if let Some(st) = self.app.settings.as_mut() {
let render = SettingsEditorRenderParams {
rows: Vec::new(),
cursor: None,
footer: None,
};
st.edit_mode = Some(super::settings::ActiveEditor::PluginCustom {
plugin_id: "demo".to_string(),
category: "general".to_string(),
field: "token".to_string(),
render: PluginEditorSession {
plugin_id: "demo".to_string(),
category: "general".to_string(),
field: "token".to_string(),
render,
},
});
self.app
.modal_stack
.push(super::focus::PaneId::PluginEditor);
}
self
}
pub fn plugin_editor_active(&self) -> bool {
matches!(
self.app.settings.as_ref().map(|st| &st.edit_mode),
Some(Some(super::settings::ActiveEditor::PluginCustom { .. }))
)
}
pub fn scroll_back(&self) -> u16 {
self.app.transcript.scroll_back_pos()
}
pub fn scroll_pinned(&self) -> bool {
self.app.transcript.is_pinned()
}
pub fn render_count(&self) -> usize {
self.app.transcript.probe_render_count()
}
pub fn cum_height_writes(&self) -> usize {
self.app.transcript.probe_cum_write_count()
}
pub fn reset_perf_probe(&self) {
self.app.transcript.probe_reset()
}
pub fn reset_highlight_probe(&self) {
super::highlight::highlight_reset_counters();
}
pub fn highlight_call_count(&self) -> usize {
super::highlight::highlight_call_count()
}
pub fn syntax_set_was_touched(&self) -> bool {
super::highlight::syntax_set_was_touched()
}
pub fn tool_use_start(&mut self, tool_id: &str, tool_name: &str) -> &mut Self {
self.app
.on_tool_use_start(tool_id.to_string(), tool_name.to_string());
self
}
pub fn tool_use_delta(&mut self, tool_id: &str, delta: &str) -> &mut Self {
self.app.on_tool_use_delta(tool_id, delta);
self
}
pub fn advance_clock_ms(&mut self, ms: u64) -> &mut Self {
self.app.clock.advance(std::time::Duration::from_millis(ms));
self
}
pub fn tick_animations(&mut self, frames: usize) -> &mut Self {
for _ in 0..frames {
self.app.tick_message_animations();
}
self
}
pub fn push_toast_with_ttl_secs(&mut self, id: &str, text: &str, ttl_secs: u64) -> &mut Self {
let toast =
super::toast::Toast::new(id, text).ttl(Some(std::time::Duration::from_secs(ttl_secs)));
self.app.toasts.upsert(toast);
self
}
pub fn push_center_toast(&mut self, id: &str, text: &str) -> &mut Self {
let toast = super::toast::Toast::new(id, text)
.at(super::toast::ToastPosition::CENTER)
.ttl(Some(std::time::Duration::from_secs(3600)));
self.app.toasts.upsert(toast);
self
}
pub fn tick_toasts(&mut self) -> bool {
self.app.toasts.tick()
}
pub fn toast_count(&self) -> usize {
self.app.toasts.visible().count()
}
pub fn no_confirm_answers(&mut self) -> &mut Self {
self.prompt_bridge
.set_confirm_policy(super::session_link::ConfirmPolicy::ViewOnly);
self
}
pub fn set_streaming(&mut self, streaming: bool) -> &mut Self {
self.app.streaming = streaming;
self
}
pub fn set_build_id(&mut self, id: &str) -> &mut Self {
super::header::set_build_id_for_thread(Some(id));
self
}
pub fn set_folder(&mut self, folder: &str) -> &mut Self {
self.app.header_folder = folder.to_string();
self
}
pub fn set_observing(&mut self, observing: bool) -> &mut Self {
self.app.observing = observing;
self
}
pub fn set_session_name(&mut self, name: &str) -> &mut Self {
self.app.session.name = Some(name.to_string());
self
}
pub fn set_status_text(&mut self, text: Option<&str>) -> &mut Self {
self.app.status_text = text.map(str::to_string);
self
}
pub fn feed_events(&mut self, events: &[SessionEventWire]) -> &mut Self {
for ev in events {
self.feed_event(ev.clone());
}
self
}
pub fn feed_event(&mut self, event: SessionEventWire) -> &mut Self {
let env = agent_engine::session::Envelope {
session_id: self.link.transport().session_id().clone(),
seq: 0,
ts: chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap_or_default(),
event,
};
let render_handle = super::render_thread::RenderHandle::headless();
let what = "handle_session_event_arm";
let flow = block_on_bounded(
what,
super::stream_handler::handle_session_event_arm(
env,
&mut self.app,
&mut self.link,
&self.registry,
&render_handle,
&mut self.prompt_bridge,
None,
),
);
if let super::stream_handler::ArmFlow::Ended = flow {
self.actions.push("session-ended".to_string());
}
self.app.secret_prompts.poll_requests(&self.secret_prompt_rx);
input::reconcile_secret_prompt(&mut self.app);
self.app.secret_prompts.arm_now_for_tests();
self
}
pub fn drive_watch_poll(&mut self) -> Option<u64> {
let now = std::time::Instant::now();
block_on_bounded(
"watch::poll",
super::watch::poll(&mut self.app, &mut self.link, now),
);
super::watch::pending_poll(&self.app).map(|(id, _)| id)
}
pub fn watch_poll_due(&self) -> bool {
super::watch::poll_due_at(&self.app).is_some()
}
pub fn watched(&self) -> Option<String> {
super::watch::focused(&self.app).map(str::to_string)
}
pub fn main_transcript_text(&self) -> String {
self.app
.transcript
.messages()
.iter()
.map(|m| describe(&m.msg))
.collect::<Vec<_>>()
.join("\n")
}
pub fn worker_transcript_text(&self, handle_id: &str) -> String {
self.app
.threads
.worker(handle_id)
.map(|w| {
w.store
.messages()
.iter()
.map(|m| describe(&m.msg))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
pub fn set_history(&mut self, msgs: Vec<synaps_cli::SharedMessage>) -> &mut Self {
*self.scripted_history.lock().unwrap() = msgs;
self
}
pub fn sent_commands(&self) -> Vec<String> {
self.scripted_log.lock().unwrap().sent.clone()
}
pub fn drive_slash_commands(&mut self) -> &mut Self {
for (cmd, arg) in std::mem::take(&mut self.pending_slash) {
self.run_slash_command(&cmd, &arg);
}
self
}
pub fn run_slash_command(&mut self, cmd: &str, arg: &str) -> &mut Self {
let system_prompt_path = std::env::temp_dir().join("synaps-harness-system-prompt.md");
let what = format!("handle_command(/{cmd} {arg})");
let action = block_on_bounded(
&what,
super::commands::handle_command(
cmd,
arg,
&mut self.app,
&mut self.link,
&system_prompt_path,
&self.registry,
&self.keybinds,
),
);
self.apply_command_action(action);
self
}
pub fn drive_streaming_input(&mut self) -> &mut Self {
use super::commands::{CommandAction, StreamingSlash};
for text in std::mem::take(&mut self.pending_streaming) {
match super::commands::resolve_streaming_input(&text, &mut self.app, &self.registry) {
StreamingSlash::Steer => self.actions.push(format!("streaming-steer:{text}")),
StreamingSlash::Refused => {
self.actions.push(format!("streaming-refused:{text}"))
}
StreamingSlash::Ran(CommandAction::OpenSettings) => {
super::settings::open_modal(&mut self.app);
}
StreamingSlash::Ran(CommandAction::OpenSessions) => {
self.open_sessions_with(None);
}
StreamingSlash::Ran(CommandAction::Quit) => self.quit_requested = true,
StreamingSlash::Ran(_) => {}
}
}
#[cfg(debug_assertions)]
super::focus::debug_assert_stack_sync(&self.app);
self
}
pub fn apply_setting(&mut self, key: &'static str, value: &str) -> &mut Self {
let what = format!("apply_setting({key}={value})");
block_on_bounded(
&what,
super::helpers::apply_setting(key, value, &mut self.app, &mut self.link),
);
self
}
pub fn is_streaming(&self) -> bool {
self.app.streaming
}
pub fn settings_row_note(&self) -> Option<(String, String)> {
self.app.settings.as_ref().and_then(|s| s.row_error.clone())
}
pub fn theme_transition_active(&self) -> bool {
self.app.theme_transition.is_some()
}
pub fn view_model(&self) -> String {
self.link.view().model.clone()
}
fn apply_command_action(&mut self, action: super::commands::CommandAction) {
use super::commands::CommandAction;
match action {
CommandAction::None | CommandAction::StartStream => {}
CommandAction::Quit => {
self.quit_requested = true;
}
CommandAction::OpenHelpFind { query } => {
let help_registry = synaps_cli::help::HelpRegistry::new(
synaps_cli::help::builtin_entries(),
self.registry.plugin_help_entries(),
);
self.app.help_find = Some(synaps_cli::help::HelpFindState::new(
help_registry.entries().to_vec(),
&query,
));
self.app.modal_stack.push(super::focus::PaneId::HelpFind);
}
CommandAction::OpenEffort => {
if !self.app.streaming {
self.app.effort = Some(super::effort::EffortModalState::new(
&self.link.view().model,
&self.link.view().thinking_level,
));
self.app.modal_stack.push(super::focus::PaneId::Effort);
}
}
CommandAction::OpenModels => {
self.app.models = Some(super::models::ModelsModalState::new());
self.app.modal_stack.push(super::focus::PaneId::Models);
}
CommandAction::OpenSettings => super::settings::open_modal(&mut self.app),
CommandAction::OpenSessions => {
self.open_sessions_with(None);
}
CommandAction::OpenPlugins => {
self.app.plugins = Some(super::plugins::PluginsModalState::new(
synaps_cli::skills::state::PluginsState::default(),
));
self.app.modal_stack.push(super::focus::PaneId::Plugins);
}
CommandAction::PluginCommand { command, arg } => {
block_on_bounded(
"execute_command_action(PluginCommand)",
super::commands::execute_command_action(
CommandAction::PluginCommand { command, arg },
&mut self.app,
&mut self.link,
),
);
}
CommandAction::LaunchGamba => self.record_unexecuted("launch-gamba"),
CommandAction::ReloadPlugins => self.record_unexecuted("reload-plugins"),
CommandAction::LoadSkill { .. } => self.record_unexecuted("load-skill"),
_ => self.record_unexecuted("other"),
}
#[cfg(debug_assertions)]
super::focus::debug_assert_stack_sync(&self.app);
}
fn record_unexecuted(&mut self, name: &str) {
self.actions
.push(format!("command-action-unexecuted:{name}"));
}
fn record(&mut self, action: InputAction) {
let desc = match action {
InputAction::None | InputAction::HelpFindOutcome => return,
InputAction::Quit => {
self.quit_requested = true;
"quit".to_string()
}
InputAction::Submit(text) => format!("submit:{text}"),
InputAction::SlashCommand(cmd, arg) => {
let desc = format!("slash:{cmd}:{arg}");
self.pending_slash.push((cmd, arg));
desc
}
InputAction::StreamingInput(text) => {
self.pending_streaming.push(text.clone());
format!("streaming-input:{text}")
}
InputAction::SubagentSteer { handle_id, text } => {
super::watch::steer_sent(&mut self.app, &handle_id, &text);
format!("subagent-steer:{handle_id}:{text}")
}
InputAction::Abort => "abort".to_string(),
InputAction::SettingsApply(key, value) => format!("settings-apply:{key}={value}"),
InputAction::ModelsApply(model) => format!("models-apply:{model}"),
InputAction::GrantWorkerModel(model) => format!("grant-worker-model:{model}"),
InputAction::EffortApply(apply) => format!(
"effort-apply:{}:{}:{}",
apply.model, apply.generation, apply.value
),
InputAction::ModelsExpandProvider(p) => format!("models-expand:{p}"),
InputAction::SessionsOp(op) => match op {
super::sessions::SessionsOp::Pin { id, on } => format!("sessions-pin:{id}:{on}"),
super::sessions::SessionsOp::ClearPin { id } => format!("sessions-clear-pin:{id}"),
super::sessions::SessionsOp::PinPast { journal_id, .. } => format!("sessions-pin-past:{journal_id}"),
super::sessions::SessionsOp::Close { id, .. } => format!("sessions-close:{id}"),
},
InputAction::SessionsPast(cmd) => match cmd {
super::sessions::past::PastCmd::Deep { search } => format!("sessions-deep:{search}"),
super::sessions::past::PastCmd::Rename { id, name } => {
format!("sessions-rename:{id}:{}", name.unwrap_or_default())
}
},
InputAction::CopyText(text) => format!("copy:{text}"),
InputAction::PluginsOutcome(_) => "plugins-outcome".to_string(),
InputAction::OpenPluginsMarketplace => "open-plugins-marketplace".to_string(),
InputAction::PingModels => "ping-models".to_string(),
InputAction::PluginEditorOpen {
plugin_id,
category,
field,
} => {
format!("plugin-editor-open:{plugin_id}:{category}:{field}")
}
InputAction::PluginEditorKey { plugin_id, .. } => {
format!("plugin-editor-key:{plugin_id}")
}
};
self.actions.push(desc);
}
}
fn describe(msg: &super::transcript::ChatMessage) -> String {
use super::transcript::ChatMessage as M;
let kind = match msg {
M::User(_) => "user",
M::Thinking(_) => "thinking",
M::Text(_) => "text",
M::ToolUseStart { .. } => "tool-start",
M::ToolUse { .. } => "tool",
M::ToolResult { .. } => "tool-result",
M::Error(_) => "error",
M::System(_) => "system",
M::Event { .. } => "event",
};
format!("{kind}: {}", msg.source_text())
}
#[derive(Clone, Default)]
struct SharedSink(Arc<parking_lot::Mutex<Vec<u8>>>);
impl SharedSink {
fn take(&self) -> Vec<u8> {
std::mem::take(&mut *self.0.lock())
}
}
impl std::io::Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub mod termcaps {
pub use super::super::termcaps::{
parse_burst_replies, write_query_burst, BurstReplies, TermCaps, BURST_TIMEOUT, QUERY_BURST,
};
}
pub mod probe {
pub fn highlight_reset() {
super::super::highlight::highlight_reset_counters();
}
pub fn highlight_call_count() -> usize {
super::super::highlight::highlight_call_count()
}
pub fn syntax_set_was_touched() -> bool {
super::super::highlight::syntax_set_was_touched()
}
}