use super::*;
pub(crate) enum TuiClient {
Embedded(in_process::InProcessApi),
External(ApiClient),
}
impl TuiApi for TuiClient {
fn fetch_sessions(&self) -> BoxFuture<'_, Result<Vec<SessionSummary>, String>> {
match self {
Self::Embedded(client) => client.fetch_sessions(),
Self::External(client) => client.fetch_sessions(),
}
}
fn fetch_thought_config(&self) -> BoxFuture<'_, Result<ThoughtConfigResponse, String>> {
match self {
Self::Embedded(client) => client.fetch_thought_config(),
Self::External(client) => client.fetch_thought_config(),
}
}
fn update_thought_config(
&self,
config: ThoughtConfig,
) -> BoxFuture<'_, Result<ThoughtConfig, String>> {
match self {
Self::Embedded(client) => client.update_thought_config(config),
Self::External(client) => client.update_thought_config(config),
}
}
fn test_thought_config(
&self,
config: ThoughtConfig,
) -> BoxFuture<'_, Result<ThoughtConfigTestResponse, String>> {
match self {
Self::Embedded(client) => client.test_thought_config(config),
Self::External(client) => client.test_thought_config(config),
}
}
fn refresh_openrouter_candidates(&self) -> BoxFuture<'_, Result<Vec<String>, String>> {
match self {
Self::Embedded(client) => client.refresh_openrouter_candidates(),
Self::External(client) => client.refresh_openrouter_candidates(),
}
}
fn fetch_mermaid_artifact(
&self,
session_id: &str,
) -> BoxFuture<'_, Result<MermaidArtifactResponse, String>> {
match self {
Self::Embedded(client) => client.fetch_mermaid_artifact(session_id),
Self::External(client) => client.fetch_mermaid_artifact(session_id),
}
}
fn fetch_plan_file(
&self,
session_id: &str,
name: &str,
) -> BoxFuture<'_, Result<PlanFileResponse, String>> {
match self {
Self::Embedded(client) => client.fetch_plan_file(session_id, name),
Self::External(client) => client.fetch_plan_file(session_id, name),
}
}
fn fetch_native_status(&self) -> BoxFuture<'_, Result<NativeDesktopStatusResponse, String>> {
match self {
Self::Embedded(client) => client.fetch_native_status(),
Self::External(client) => client.fetch_native_status(),
}
}
fn set_native_app(
&self,
app: NativeDesktopApp,
) -> BoxFuture<'_, Result<NativeDesktopStatusResponse, String>> {
match self {
Self::Embedded(client) => client.set_native_app(app),
Self::External(client) => client.set_native_app(app),
}
}
fn set_native_mode(
&self,
mode: GhosttyOpenMode,
) -> BoxFuture<'_, Result<NativeDesktopStatusResponse, String>> {
match self {
Self::Embedded(client) => client.set_native_mode(mode),
Self::External(client) => client.set_native_mode(mode),
}
}
fn publish_selection(&self, session_id: Option<&str>) -> BoxFuture<'_, Result<(), String>> {
match self {
Self::Embedded(client) => client.publish_selection(session_id),
Self::External(client) => client.publish_selection(session_id),
}
}
fn open_session(
&self,
session_id: &str,
) -> BoxFuture<'_, Result<NativeDesktopOpenResponse, String>> {
match self {
Self::Embedded(client) => client.open_session(session_id),
Self::External(client) => client.open_session(session_id),
}
}
fn list_dirs(
&self,
path: Option<&str>,
managed_only: bool,
group: Option<&str>,
) -> BoxFuture<'_, Result<DirListResponse, String>> {
match self {
Self::Embedded(client) => client.list_dirs(path, managed_only, group),
Self::External(client) => client.list_dirs(path, managed_only, group),
}
}
fn start_repo_action(
&self,
path: &str,
kind: RepoActionKind,
) -> BoxFuture<'_, Result<DirRepoActionResponse, String>> {
match self {
Self::Embedded(client) => client.start_repo_action(path, kind),
Self::External(client) => client.start_repo_action(path, kind),
}
}
fn create_session(
&self,
cwd: &str,
spawn_tool: SpawnTool,
initial_request: Option<String>,
) -> BoxFuture<'_, Result<CreateSessionResponse, String>> {
match self {
Self::Embedded(client) => client.create_session(cwd, spawn_tool, initial_request),
Self::External(client) => client.create_session(cwd, spawn_tool, initial_request),
}
}
}
fn external_mode_requested() -> bool {
std::env::var_os("SWIMMERS_TUI_URL").is_some()
}
fn is_loopback_base_url(base_url: &str) -> Result<bool, String> {
let parsed = reqwest::Url::parse(base_url)
.map_err(|err| format!("invalid SWIMMERS_TUI_URL `{base_url}`: {err}"))?;
Ok(match parsed.host_str() {
Some("localhost") => true,
Some(host) => host
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false),
None => false,
})
}
fn build_external_client(runtime: &Runtime) -> Result<TuiClient, io::Error> {
let client = ApiClient::from_env().map_err(io::Error::other)?;
if is_loopback_base_url(&client.base_url).map_err(io::Error::other)? {
let handle = runtime
.block_on(lifecycle::ensure_server(
&client.base_url,
Duration::from_secs(10),
))
.map_err(io::Error::other)?;
tracing::info!(?handle, url = %client.base_url, "external mode backend ready");
} else {
tracing::info!(url = %client.base_url, "external mode with remote backend URL");
}
runtime
.block_on(client.preflight_startup_access())
.map_err(io::Error::other)?;
Ok(TuiClient::External(client))
}
fn build_embedded_client(runtime: &Runtime) -> TuiClient {
let config = Arc::new(Config::from_env());
let (state, _thought_handle, _bridge_health) =
runtime.block_on(swimmers::startup::init_app_state(config));
tracing::info!("embedded mode initialized");
TuiClient::Embedded(in_process::InProcessApi::new(state))
}
pub(crate) fn initialize_tui_app() -> Result<(App<TuiClient>, Renderer), Box<dyn std::error::Error>>
{
let _ = dotenvy::dotenv();
swimmers::env_bootstrap::bootstrap_provider_env_from_shell();
let runtime = Runtime::new()?;
let client = if external_mode_requested() {
build_external_client(&runtime)?
} else {
build_embedded_client(&runtime)
};
let mut renderer = Renderer::new()?;
renderer.init()?;
let mut app = App::new(runtime, client);
let initial_layout = app.layout_for_terminal(renderer.width(), renderer.height());
app.refresh(initial_layout);
Ok((app, renderer))
}
pub(crate) fn prepare_frame<C: TuiApi>(
app: &mut App<C>,
renderer: &mut Renderer,
) -> WorkspaceLayout {
let layout = app.layout_for_terminal(renderer.width(), renderer.height());
if layout.split_divider.is_none() {
app.stop_split_drag();
}
app.trim_thought_log(layout.thought_entry_capacity());
app.poll_pending_selection_publication();
app.poll_pending_interaction();
app.poll_refresh(layout);
app.maybe_refresh_picker();
if app.should_refresh() && app.pending_refresh.is_none() {
app.spawn_background_refresh(false);
}
app.tick(layout.overview_field);
app.render(renderer, layout);
layout
}
pub(crate) fn handle_key_event<C: TuiApi>(
app: &mut App<C>,
layout: WorkspaceLayout,
key: KeyEvent,
) -> bool {
if app.thought_config_editor.is_some() {
app.handle_thought_config_key(key, layout);
return true;
}
if app.initial_request.is_some() {
app.handle_initial_request_key(key, layout.overview_field);
return true;
}
if let FishBowlMode::Mermaid(viewer) = &app.fish_bowl_mode {
let content_rect = viewer
.content_rect
.unwrap_or_else(|| mermaid_content_rect(layout.overview_field));
let is_text_tab = viewer.active_tab != DomainPlanTab::Schema;
let has_tabs = viewer.plan_tabs.is_some();
if has_tabs {
match key.code {
KeyCode::Char('[') => {
app.cycle_plan_tab(-1);
return true;
}
KeyCode::Char(']') => {
app.cycle_plan_tab(1);
return true;
}
KeyCode::Char(c @ '1'..='9') => {
let idx = (c as usize) - ('1' as usize);
if let FishBowlMode::Mermaid(v) = &app.fish_bowl_mode {
if let Some(tabs) = &v.plan_tabs {
if let Some(&tab) = tabs.get(idx) {
app.switch_plan_tab(tab);
return true;
}
}
}
return true;
}
_ => {}
}
}
if is_text_tab {
return match key.code {
KeyCode::Char('q') => false,
KeyCode::Esc => {
app.close_mermaid_viewer();
true
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_plan_text(-1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_plan_text(1);
true
}
KeyCode::PageUp => {
app.scroll_plan_text_page(-1);
true
}
KeyCode::PageDown => {
app.scroll_plan_text_page(1);
true
}
KeyCode::Home => {
if let Some(viewer) = app.mermaid_viewer_mut() {
viewer.plan_text_scroll = 0;
}
true
}
KeyCode::End => {
if let Some(viewer) = app.mermaid_viewer_mut() {
viewer.plan_text_scroll = viewer.plan_text_lines.len().saturating_sub(1);
}
true
}
KeyCode::Char('o') => {
app.open_mermaid_artifact();
true
}
_ => true,
};
}
let (step_x, step_y) = mermaid_pan_step(viewer, content_rect);
return match key.code {
KeyCode::Char('q') => false,
KeyCode::Esc => {
if !app.clear_mermaid_focus() {
app.close_mermaid_viewer();
}
true
}
KeyCode::Tab => {
app.focus_next_mermaid_target(content_rect);
true
}
KeyCode::BackTab => {
app.focus_previous_mermaid_target(content_rect);
true
}
KeyCode::Left | KeyCode::Char('h') => {
app.pan_mermaid_viewer(-step_x, 0.0);
true
}
KeyCode::Right | KeyCode::Char('l') => {
app.pan_mermaid_viewer(step_x, 0.0);
true
}
KeyCode::Up | KeyCode::Char('k') => {
app.pan_mermaid_viewer(0.0, -step_y);
true
}
KeyCode::Down | KeyCode::Char('j') => {
app.pan_mermaid_viewer(0.0, step_y);
true
}
KeyCode::Char('+') | KeyCode::Char('=') => {
app.zoom_mermaid_viewer(MERMAID_KEYBOARD_ZOOM_STEP_PERCENT, None, content_rect);
true
}
KeyCode::Char('-') => {
app.zoom_mermaid_viewer(-MERMAID_KEYBOARD_ZOOM_STEP_PERCENT, None, content_rect);
true
}
KeyCode::Char('o') => {
app.open_mermaid_artifact();
true
}
KeyCode::Char('0') => {
app.reset_mermaid_viewer_fit();
true
}
_ => true,
};
}
match key.code {
KeyCode::Char('q') => false,
KeyCode::Esc => {
if app.picker.is_some() {
app.close_picker();
true
} else {
false
}
}
KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace => {
if app.picker.is_some() {
app.picker_up();
} else {
app.move_selection(-1, layout.overview_field);
}
true
}
KeyCode::Up | KeyCode::Char('k') => {
app.move_selection(-1, layout.overview_field);
true
}
KeyCode::Down | KeyCode::Char('j') => {
app.move_selection(1, layout.overview_field);
true
}
KeyCode::Char('m') => {
app.toggle_ghostty_mode();
true
}
KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter | KeyCode::Char('o') => {
if app.picker.is_some() {
app.picker_activate_selection(layout.overview_field);
} else {
app.open_selected();
}
true
}
KeyCode::Char('e') => {
app.picker_set_managed_only(true);
true
}
KeyCode::Char('a') => {
app.picker_set_managed_only(false);
true
}
KeyCode::Char('c') if app.picker.is_some() => {
app.picker_start_action_for_selection(RepoActionKind::Commit);
true
}
KeyCode::Char('R') if app.picker.is_some() => {
app.picker_start_action_for_selection(RepoActionKind::Restart);
true
}
KeyCode::Char('O') if app.picker.is_some() => {
app.picker_open_url_for_selection();
true
}
KeyCode::Char('r') => {
if let Some((path, managed_only, group)) = app.picker.as_ref().map(|picker| {
(
picker.current_path.clone(),
picker.managed_only,
picker.current_group.clone(),
)
}) {
app.picker_reload(Some(path), managed_only, group);
} else {
app.manual_refresh(layout);
}
true
}
KeyCode::Char('t') => {
app.open_thought_config_editor();
true
}
KeyCode::Char('n') => {
app.toggle_native_app();
true
}
KeyCode::Char('s') => {
app.toggle_sprite_theme();
true
}
_ => true,
}
}
pub(crate) fn handle_mouse_down<C: TuiApi>(
app: &mut App<C>,
renderer: &Renderer,
layout: WorkspaceLayout,
mouse: crossterm::event::MouseEvent,
) {
if app.thought_config_editor.is_some() {
return;
}
if app.initial_request.is_some() {
return;
}
if handle_split_or_header_click(app, renderer.width(), layout, mouse) {
return;
}
if app.handle_mermaid_mouse_down(layout.overview_field, mouse) {
return;
}
handle_workspace_click(app, layout, mouse);
}
pub(crate) fn handle_split_or_header_click<C: TuiApi>(
app: &mut App<C>,
width: u16,
layout: WorkspaceLayout,
mouse: crossterm::event::MouseEvent,
) -> bool {
if layout
.split_hitbox
.map(|hitbox| hitbox.contains(mouse.column, mouse.row))
.unwrap_or(false)
{
app.start_split_drag(layout, mouse.column);
return true;
}
if header_filter_action_at(app, width, mouse.column, mouse.row).is_some() {
app.handle_header_filter_click(width, mouse.column, mouse.row);
return true;
}
if app
.sprite_theme_rect(width)
.contains(mouse.column, mouse.row)
{
app.set_sprite_theme_from_click(mouse.column);
return true;
}
if app
.ghostty_mode_rect(width)
.map(|rect| rect.contains(mouse.column, mouse.row))
.unwrap_or(false)
{
app.toggle_ghostty_mode();
return true;
}
if app
.native_status_rect(width)
.map(|rect| rect.contains(mouse.column, mouse.row))
.unwrap_or(false)
{
app.toggle_native_app();
return true;
}
false
}
pub(crate) fn handle_workspace_click<C: TuiApi>(
app: &mut App<C>,
layout: WorkspaceLayout,
mouse: crossterm::event::MouseEvent,
) {
if let Some(thought_box) = layout.thought_box {
if thought_box.contains(mouse.column, mouse.row) {
if let Some(thought_content) = layout.thought_content {
app.handle_thought_click(
mouse.column,
mouse.row,
thought_content,
layout.thought_entry_capacity(),
);
}
return;
}
}
if layout.overview_field.contains(mouse.column, mouse.row) {
app.handle_field_click(mouse.column, mouse.row, layout.overview_field);
}
}
pub(crate) fn handle_tui_event<C: TuiApi>(
app: &mut App<C>,
renderer: &mut Renderer,
layout: WorkspaceLayout,
event: Event,
) -> io::Result<bool> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
Ok(handle_key_event(app, layout, key))
}
Event::Paste(text) => {
app.handle_paste(&text);
Ok(true)
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) => {
handle_mouse_down(app, renderer, layout, mouse);
Ok(true)
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Drag(MouseButton::Left)) => {
if app.drag_split(layout, mouse.column) {
return Ok(true);
}
if app.handle_mermaid_mouse_drag(layout.overview_field, mouse) {
return Ok(true);
}
Ok(true)
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) => {
app.stop_split_drag();
app.handle_mermaid_mouse_up();
Ok(true)
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::ScrollUp) => {
let _ =
app.handle_mermaid_scroll(layout.overview_field, mouse, MermaidZoomDirection::In);
Ok(true)
}
Event::Mouse(mouse) if matches!(mouse.kind, MouseEventKind::ScrollDown) => {
let _ =
app.handle_mermaid_scroll(layout.overview_field, mouse, MermaidZoomDirection::Out);
Ok(true)
}
Event::Resize(width, height) => {
app.stop_split_drag();
renderer.manual_resize(width, height)?;
Ok(true)
}
_ => Ok(true),
}
}