aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/commands/inline.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/commands/inline.rs')
-rw-r--r--crates/atuin-ai/src/commands/inline.rs680
1 files changed, 271 insertions, 409 deletions
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index 7ceaf5b5..c16e3dac 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -1,29 +1,22 @@
+use std::sync::mpsc;
+
use crate::commands::detect_shell;
-use crate::tui::render::render;
-use crate::tui::{
- App, AppEvent, AppMode, ConversationEvent, EventLoop, ExitAction, RenderContext, TerminalGuard,
- calculate_needed_height, install_panic_hook,
-};
+use crate::tui::events::AiTuiEvent;
+use crate::tui::state::{AppState, ExitAction};
+use crate::tui::view::ai_view;
use atuin_client::distro::detect_linux_distribution;
-use atuin_client::theme::ThemeManager;
use atuin_common::tls::ensure_crypto_provider;
-use crossterm::{
- event::{self, Event, KeyCode},
- terminal::{disable_raw_mode, enable_raw_mode},
-};
use eventsource_stream::Eventsource;
+use eye_declare::{Application, CtrlCBehavior, Handle};
use eyre::{Context as _, Result, bail};
use futures::StreamExt;
use reqwest::Url;
-use std::io::Write;
use tracing::{debug, error, info, trace};
pub async fn run(
initial_command: Option<String>,
api_endpoint: Option<String>,
api_token: Option<String>,
- keep_output: bool,
- debug_state_file: Option<String>,
settings: &atuin_client::settings::Settings,
output_for_hook: bool,
) -> Result<()> {
@@ -38,13 +31,6 @@ pub async fn run(
return Ok(());
}
- // Install panic hook once at entry point to ensure terminal restoration
- install_panic_hook();
-
- // Token and endpoint priority:
- // 1. Command line arguments/environment variables
- // 2. Settings file
- // 3. Default
let endpoint = api_endpoint.as_deref().unwrap_or(
settings
.ai
@@ -57,23 +43,10 @@ pub async fn run(
let token = if let Some(token) = &api_token {
token.to_string()
} else {
- // ensure_hub_session will authenticate against settings.active_hub_endpoint().unwrap_or_default(),
- // which is the default Hub endpoint if no endpoint is provided
- //
- // TODO[mkt]: Atuin AI and the Hub sync endpoint are too tightly coupled;
- // current setup means that Hub endpoint controls auth while AI endpoint controls AI conversations
ensure_hub_session(settings).await?
};
- let action = run_inline_tui(
- endpoint.to_string(),
- token,
- initial_command,
- keep_output,
- debug_state_file,
- settings,
- )
- .await?;
+ let action = run_inline_tui(endpoint.to_string(), token, initial_command, settings).await?;
emit_shell_result(action, output_for_hook);
Ok(())
@@ -86,7 +59,6 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
}
let hub_address = settings.active_hub_endpoint().unwrap_or_default();
-
let will_sync = settings.is_hub_sync();
info!("No Hub session found, prompting for authentication");
@@ -106,8 +78,8 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
}
debug!("Starting Atuin Hub authentication...");
-
println!("Authenticating with Atuin Hub...");
+
let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to continue:");
println!("{}", session.auth_url);
@@ -120,17 +92,13 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
.await?;
info!("Authentication complete, saving session token");
-
atuin_client::hub::save_session(&token).await?;
- // Silently attempt to link CLI account to Hub if one exists
- // This enables unified auth - users can use their Hub token for sync
if let Ok(meta) = atuin_client::settings::Settings::meta_store().await
&& let Ok(Some(cli_token)) = meta.session_token().await
{
debug!("CLI session found, attempting to link accounts");
if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await {
- // Don't fail AI flow if linking fails - it's not critical
debug!("Could not link CLI account to Hub: {}", e);
} else {
info!("Successfully linked CLI account to Hub");
@@ -140,28 +108,27 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
Ok(token)
}
-/// SSE event received from chat endpoint
+// ───────────────────────────────────────────────────────────────────
+// SSE streaming
+// ───────────────────────────────────────────────────────────────────
+
#[derive(Debug, Clone)]
enum ChatStreamEvent {
- /// Text chunk to display
TextChunk(String),
- /// Tool call event (need to echo back, may contain suggest_command)
ToolCall {
id: String,
name: String,
input: serde_json::Value,
},
- /// Tool result from server-side execution
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
- /// Status update from server
Status(String),
- /// Stream complete
- Done { session_id: String },
- /// Error from server
+ Done {
+ session_id: String,
+ },
Error(String),
}
@@ -170,10 +137,8 @@ fn create_chat_stream(
token: String,
session_id: Option<String>,
messages: Vec<serde_json::Value>,
- settings: &atuin_client::settings::Settings,
+ send_cwd: bool,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>> {
- let send_cwd = settings.ai.send_cwd;
-
Box::pin(async_stream::stream! {
ensure_crypto_provider();
let endpoint = match hub_url(&hub_address, "/api/cli/chat") {
@@ -201,19 +166,16 @@ fn create_chat_stream(
context["distro"] = serde_json::json!(detect_linux_distribution());
}
- // Build request body
let mut request_body = serde_json::json!({
"messages": messages,
"context": context,
});
- // Include session_id only if present (not on first request)
if let Some(ref sid) = session_id {
trace!("Including session_id in request: {sid}");
request_body["session_id"] = serde_json::json!(sid);
}
-
let client = reqwest::Client::new();
let response = match client
.post(endpoint.clone())
@@ -232,7 +194,6 @@ fn create_chat_stream(
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
- // Clear saved session on auth error
error!("SSE request failed with status: {status}, clearing session");
let _ = atuin_client::hub::delete_session().await;
yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again."));
@@ -310,9 +271,7 @@ fn create_chat_stream(
}
break;
}
- _ => {
- // Unknown event type, ignore
- }
+ _ => {}
}
}
Err(e) => {
@@ -324,404 +283,296 @@ fn create_chat_stream(
})
}
-fn hub_url(base: &str, path: &str) -> Result<Url> {
- let base_with_slash = if base.ends_with('/') {
- base.to_string()
- } else {
- format!("{base}/")
- };
- let stripped = path.strip_prefix('/').unwrap_or(path);
- Url::parse(&base_with_slash)?
- .join(stripped)
- .context("failed to build hub URL")
-}
-
-fn detect_os() -> String {
- match std::env::consts::OS {
- "macos" => "macos".to_string(),
- "linux" => "linux".to_string(),
- "windows" => "windows".to_string(),
- other => format!("Other: {other}"),
- }
-}
-
-#[derive(Clone)]
-enum Action {
- Execute(String),
- Insert(String),
- Print(String),
- Cancel,
-}
-
-/// Serialize AppState to JSON for debug logging
-fn state_to_json(state: &crate::tui::AppState) -> serde_json::Value {
- let events: Vec<serde_json::Value> = state.events.iter().map(|e| e.to_json()).collect();
-
- let mode = match state.mode {
- AppMode::Input => "Input",
- AppMode::Generating => "Generating",
- AppMode::Streaming => "Streaming",
- AppMode::Review => "Review",
- AppMode::Error => "Error",
- };
-
- // Get input and cursor from textarea
- let input = state.input();
- let cursor = state.textarea.cursor();
-
- let mut json = serde_json::json!({
- "events": events,
- "mode": mode,
- "input": input,
- "cursor_row": cursor.0,
- "cursor_col": cursor.1,
- "spinner_frame": state.spinner_frame,
- "confirmation_pending": state.confirmation_pending,
- });
+// ───────────────────────────────────────────────────────────────────
+// Async streaming task — pushes updates to app state via Handle
+// ───────────────────────────────────────────────────────────────────
- // Add streaming fields if in streaming mode
- if !state.streaming_text.is_empty() {
- json["streaming_text"] = serde_json::json!(state.streaming_text);
- }
- if let Some(ref status) = state.streaming_status {
- json["streaming_status"] = serde_json::json!(status.display_text());
- }
- if let Some(ref err) = state.error {
- json["error"] = serde_json::json!(err);
- }
-
- json
-}
-
-/// Debug logger that writes state changes to a file
-struct DebugStateLogger {
- file: std::fs::File,
- entry_count: usize,
- width: u16,
-}
-
-impl DebugStateLogger {
- fn new(path: &str) -> Result<Self> {
- let file = std::fs::File::create(path)
- .with_context(|| format!("Failed to create debug state file: {}", path))?;
- // Get terminal width, default to 80
- let (width, _) = crossterm::terminal::size().unwrap_or((80, 24));
- Ok(Self {
- file,
- entry_count: 0,
- width,
- })
- }
-
- fn log(&mut self, label: &str, state: &crate::tui::AppState) {
- use crate::tui::calculate_needed_height;
-
- self.entry_count += 1;
- let timestamp_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis())
- .unwrap_or(0);
-
- // Calculate the actual content height needed for this state
- let content_height = calculate_needed_height(state, 0);
-
- let mut state_json = state_to_json(state);
- // Add dimensions for accurate replay
- state_json["width"] = serde_json::json!(self.width);
- state_json["height"] = serde_json::json!(content_height);
-
- let entry = serde_json::json!({
- "entry": self.entry_count,
- "label": label,
- "timestamp_ms": timestamp_ms,
- "state": state_json,
- });
+async fn run_chat_stream(
+ handle: Handle<AppState>,
+ endpoint: String,
+ token: String,
+ session_id: Option<String>,
+ messages: Vec<serde_json::Value>,
+ send_cwd: bool,
+) {
+ let stream = create_chat_stream(endpoint, token, session_id, messages, send_cwd);
+ futures::pin_mut!(stream);
- // Write as JSONL (one JSON object per line)
- if let Err(e) = writeln!(self.file, "{}", entry) {
- tracing::warn!("Failed to write debug state: {}", e);
+ while let Some(event) = stream.next().await {
+ match event {
+ Ok(ChatStreamEvent::TextChunk(text)) => {
+ trace!(text = %text, "Processing TextChunk");
+ handle.update(move |state| {
+ state.append_streaming_text(&text);
+ });
+ }
+ Ok(ChatStreamEvent::ToolCall { id, name, input }) => {
+ trace!(id = %id, name = %name, "Processing ToolCall");
+ handle.update(move |state| {
+ state.add_tool_call(id, name, input);
+ });
+ }
+ Ok(ChatStreamEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ }) => {
+ trace!(tool_use_id = %tool_use_id, "Processing ToolResult");
+ handle.update(move |state| {
+ state.add_tool_result(tool_use_id, content, is_error);
+ });
+ }
+ Ok(ChatStreamEvent::Status(status)) => {
+ trace!(status = %status, "Processing Status");
+ handle.update(move |state| {
+ state.update_streaming_status(&status);
+ });
+ }
+ Ok(ChatStreamEvent::Done { session_id }) => {
+ trace!(session_id = %session_id, "Processing Done");
+ handle.update(move |state| {
+ if !session_id.is_empty() {
+ state.store_session_id(session_id);
+ }
+ state.finalize_streaming();
+ });
+ break;
+ }
+ Ok(ChatStreamEvent::Error(msg)) => {
+ trace!(error = %msg, "Processing Error");
+ handle.update(move |state| {
+ state.streaming_error(msg);
+ });
+ break;
+ }
+ Err(e) => {
+ let msg = e.to_string();
+ handle.update(move |state| {
+ state.streaming_error(msg);
+ });
+ break;
+ }
}
- let _ = self.file.flush();
}
}
+// ───────────────────────────────────────────────────────────────────
+// Main TUI entry point
+// ───────────────────────────────────────────────────────────────────
+
async fn run_inline_tui(
endpoint: String,
token: String,
initial_prompt: Option<String>,
- keep_output: bool,
- debug_state_file: Option<String>,
settings: &atuin_client::settings::Settings,
) -> Result<Action> {
- // Detect popup mode (only on Unix where atuin-hex socket is available)
- #[cfg(unix)]
- let mut popup_state = crate::tui::popup::try_setup_popup();
- #[cfg(not(unix))]
- let popup_state: Option<()> = None;
+ let initial_state = AppState::new();
- let popup_mode = popup_state.is_some();
-
- // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline
- #[cfg(unix)]
- let mut guard = if let Some(ref ps) = popup_state {
- TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)?
- } else {
- TerminalGuard::new(keep_output)?
- };
- #[cfg(not(unix))]
- let mut guard = TerminalGuard::new(keep_output)?;
- let mut app = App::new();
- if let Some(prompt) = initial_prompt {
- // Set initial text in textarea
- let mut textarea = tui_textarea::TextArea::from(prompt.lines());
- // Disable underline on cursor line
- textarea.set_cursor_line_style(ratatui::style::Style::default());
- // Enable word wrapping
- textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
- // Move cursor to end
- textarea.move_cursor(tui_textarea::CursorMove::End);
- app.state.textarea = textarea;
- }
+ println!();
- // Initialize debug state logger if requested
- let mut debug_logger = debug_state_file
- .map(|path| DebugStateLogger::new(&path))
- .transpose()?;
+ let (tx, rx) = mpsc::channel::<AiTuiEvent>();
- // Helper macro to log state changes
- macro_rules! log_state {
- ($label:expr) => {
- if let Some(ref mut logger) = debug_logger {
- logger.log($label, &app.state);
- }
- };
+ // If there's an initial prompt, send it as a SubmitInput event
+ // so it flows through the same path as user-typed input.
+ if let Some(prompt) = initial_prompt {
+ let _ = tx.send(AiTuiEvent::SubmitInput(prompt));
}
- // Log initial state
- log_state!("init");
-
- // Load theme
- let mut theme_manager = ThemeManager::new(None, None);
- let theme = theme_manager.load_theme(&settings.theme.name, None);
-
- // Initialize event loop
- let mut event_loop = EventLoop::new();
-
- // Track chat stream
- let mut chat_stream: Option<
- std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>>,
- > = None;
+ let (mut app, handle) = Application::builder()
+ .state(initial_state)
+ .view(ai_view)
+ .ctrl_c(CtrlCBehavior::Deliver)
+ .keyboard_protocol(eye_declare::KeyboardProtocol::Enhanced)
+ .bracketed_paste(true)
+ .with_context(tx)
+ .extra_newlines_at_exit(1)
+ .build()?;
- loop {
- // Ensure viewport is large enough for current content (capped at terminal height)
- // In popup mode, use the actual popup width for accurate height calculation
- let card_width = if popup_mode {
- #[cfg(unix)]
- {
- popup_state
- .as_ref()
- .map(|ps| {
- ps.current_rect
- .width
- .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2)
- })
- .unwrap_or(0)
- }
- #[cfg(not(unix))]
- {
- 0
- }
- } else {
- 0
- };
- let needed_height = calculate_needed_height(&app.state, card_width);
-
- // Grow popup dynamically as content arrives
- #[cfg(unix)]
- if let Some(ref mut ps) = popup_state {
- // Add vertical margin for visual separation from terminal content
- let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2);
- if let Some(new_rect) = ps.fit_to(popup_height) {
- guard.resize_popup(new_rect)?;
- }
- }
+ let send_cwd = settings.ai.send_cwd;
- let actual_height = guard.ensure_height(needed_height)?;
+ // Event loop: receives AiTuiEvent from components, mutates state via Handle.
+ let h = handle.clone();
+ let ep = endpoint.clone();
+ let tk = token.clone();
+ tokio::task::spawn_blocking(move || {
+ while let Ok(event) = rx.recv() {
+ match event {
+ AiTuiEvent::InputUpdated(input) => {
+ let input_blank = input.trim().is_empty();
- // Render current state
- let anchor_col = guard.anchor_col();
- #[cfg(unix)]
- let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above);
- #[cfg(not(unix))]
- let render_above = false;
+ h.update(move |state| {
+ state.is_input_blank = input_blank;
+ });
+ }
+ AiTuiEvent::SubmitInput(input) => {
+ let input = input.trim().to_string();
+ if input.is_empty() {
+ let h2 = h.clone();
+ h.update(move |state| {
+ if state.has_any_command() {
+ state.exit_action = Some(ExitAction::Execute(
+ state.current_command().unwrap().to_string(),
+ ));
+ } else {
+ state.exit_action = Some(ExitAction::Cancel);
+ }
+ h2.exit();
+ });
+ continue;
+ }
- let ctx = RenderContext {
- theme,
- anchor_col,
- textarea: Some(&app.state.textarea),
- max_height: actual_height,
- popup_mode,
- render_above,
- };
- // Handle draw errors gracefully - cursor position reads can fail during resize
- if let Err(e) = guard.terminal().draw(|frame| {
- render(frame, &app.state, &ctx);
- }) {
- let err_msg = e.to_string();
- if err_msg.contains("cursor position") {
- // Cursor position read failed (common during terminal resize)
- // Skip this frame and continue - next frame will likely succeed
- tracing::debug!(
- "Skipping frame due to cursor position read error: {}",
- err_msg
- );
- continue;
- }
- return Err(e.into());
- }
+ if input.starts_with('/') {
+ let input_clone = input.clone();
+ h.update(move |state| {
+ state.handle_slash_command(&input_clone);
+ });
+ continue;
+ }
- // Get next event
- let event = event_loop.run().await?;
+ // Start generation and spawn streaming task
+ let ep = ep.clone();
+ let tk = tk.clone();
+ let h2 = h.clone();
+ h.update(move |state| {
+ state.start_generating(input);
+ state.start_streaming();
+ state.is_input_blank = true;
+ let messages = state.events_to_messages();
+ let sid = state.session_id.clone();
+ let task = tokio::spawn(async move {
+ run_chat_stream(h2, ep, tk, sid, messages, send_cwd).await;
+ });
+ state.stream_abort = Some(task.abort_handle());
+ });
+ }
- // Handle event based on app mode
- match event {
- AppEvent::Key(key) => {
- app.handle_key(key);
- log_state!("key");
- }
- AppEvent::Tick => {
- app.state.tick();
+ AiTuiEvent::SlashCommand(command) => {
+ h.update(move |state| {
+ state.handle_slash_command(&command);
+ });
+ }
- // Poll chat stream if active - keep polling until done regardless of mode
- // (mode may change to Review before we receive the done event with session_id)
- if let Some(stream) = &mut chat_stream {
- let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref());
- match stream.as_mut().poll_next(&mut cx) {
- std::task::Poll::Ready(Some(Ok(event))) => match event {
- ChatStreamEvent::TextChunk(text) => {
- trace!(text = %text, "Processing TextChunk");
- app.state.append_streaming_text(&text);
- log_state!("text_chunk");
- }
- ChatStreamEvent::ToolCall { id, name, input } => {
- trace!(id = %id, name = %name, "Processing ToolCall");
- app.state.add_tool_call(id, name, input);
- log_state!("tool_call");
- }
- ChatStreamEvent::ToolResult {
- tool_use_id,
- content,
- is_error,
- } => {
- trace!(tool_use_id = %tool_use_id, "Processing ToolResult");
- app.state.add_tool_result(tool_use_id, content, is_error);
- log_state!("tool_result");
- }
- ChatStreamEvent::Status(status) => {
- trace!(status = %status, "Processing Status");
- app.state.update_streaming_status(&status);
- log_state!("status");
- }
- ChatStreamEvent::Done { session_id } => {
- trace!(session_id = %session_id, "Processing Done");
- chat_stream = None;
- if !session_id.is_empty() {
- app.state.store_session_id(session_id);
- }
- app.state.finalize_streaming();
- log_state!("done");
- }
- ChatStreamEvent::Error(msg) => {
- trace!(error = %msg, "Processing Error");
- chat_stream = None;
- app.state.streaming_error(msg);
- log_state!("error");
- }
- },
- std::task::Poll::Ready(Some(Err(e))) => {
- chat_stream = None;
- app.state.streaming_error(e.to_string());
- log_state!("stream_error");
+ AiTuiEvent::CancelGeneration => {
+ h.update(|state| match state.mode {
+ crate::tui::state::AppMode::Generating => {
+ state.cancel_generation();
}
- std::task::Poll::Ready(None) => {
- chat_stream = None;
- app.state.finalize_streaming();
- log_state!("stream_end");
+ crate::tui::state::AppMode::Streaming => {
+ state.cancel_streaming();
}
- std::task::Poll::Pending => {}
- }
+ _ => {}
+ });
}
- }
- _ => {}
- }
-
- // Handle user cancellation (Esc during streaming) - drop the stream
- if app.state.was_interrupted && chat_stream.is_some() {
- debug!("User cancelled streaming, dropping chat stream");
- chat_stream = None;
- app.state.was_interrupted = false; // Reset the flag
- }
- // Check exit condition (includes Ctrl+C / SIGINT from event loop)
- if app.state.should_exit || event_loop.is_shutdown() {
- break;
- }
+ AiTuiEvent::ExecuteCommand => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ let cmd = state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ if state.is_current_command_dangerous() && !state.confirmation_pending {
+ state.confirmation_pending = true;
+ } else {
+ state.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Execute(cmd));
+ h2.exit();
+ }
+ }
+ });
+ }
- // Handle generation trigger - unified path for all turns
- if app.state.mode == AppMode::Generating && chat_stream.is_none() {
- // Get the last user message from events
- let last_user_content = app.state.events.iter().rev().find_map(|e| {
- if let ConversationEvent::UserMessage { content } = e {
- Some(content.clone())
- } else {
- None
+ AiTuiEvent::CancelConfirmation => {
+ h.update(move |state| {
+ state.confirmation_pending = false;
+ });
}
- });
- if last_user_content.is_some() {
- // Build messages in Claude API format
- let messages = app.state.events_to_messages();
+ AiTuiEvent::InsertCommand => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ let cmd = state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ state.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Insert(cmd));
+ h2.exit();
+ }
+ });
+ }
- // Transition to streaming mode
- app.state.start_streaming();
- log_state!("start_streaming");
+ AiTuiEvent::Retry => {
+ let ep = ep.clone();
+ let tk = tk.clone();
+ let h2 = h.clone();
+ h.update(move |state| {
+ state.retry();
+ state.start_streaming();
+ let messages = state.events_to_messages();
+ let sid = state.session_id.clone();
+ let task = tokio::spawn(async move {
+ run_chat_stream(h2, ep, tk, sid, messages, send_cwd).await;
+ });
+ state.stream_abort = Some(task.abort_handle());
+ });
+ }
- // Start the chat stream
- chat_stream = Some(create_chat_stream(
- endpoint.clone(),
- token.clone(),
- app.state.session_id.clone(),
- messages,
- settings,
- ));
+ AiTuiEvent::Exit => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ if let Some(abort) = state.stream_abort.take() {
+ abort.abort();
+ }
+ state.exit_action = Some(ExitAction::Cancel);
+ h2.exit();
+ });
+ }
}
}
- }
+ });
- // Restore popup area before guard drops (guard skips cleanup in popup mode)
- #[cfg(unix)]
- if let Some(ref ps) = popup_state {
- crate::tui::popup::restore(ps);
- }
+ app.run_loop().await?;
// Map exit action to return value
- let result = match app.state.exit_action {
- Some(ExitAction::Execute(cmd)) => Action::Execute(cmd),
- Some(ExitAction::Insert(cmd)) => Action::Insert(cmd),
+ let result = match app.state().exit_action {
+ Some(ExitAction::Execute(ref cmd)) => Action::Execute(cmd.clone()),
+ Some(ExitAction::Insert(ref cmd)) => Action::Insert(cmd.clone()),
_ => Action::Cancel,
};
Ok(result)
}
-struct RawModeGuard;
+// ───────────────────────────────────────────────────────────────────
+// Helpers
+// ───────────────────────────────────────────────────────────────────
-impl Drop for RawModeGuard {
- fn drop(&mut self) {
- let _ = disable_raw_mode();
+fn hub_url(base: &str, path: &str) -> Result<Url> {
+ let base_with_slash = if base.ends_with('/') {
+ base.to_string()
+ } else {
+ format!("{base}/")
+ };
+ let stripped = path.strip_prefix('/').unwrap_or(path);
+ Url::parse(&base_with_slash)?
+ .join(stripped)
+ .context("failed to build hub URL")
+}
+
+fn detect_os() -> String {
+ match std::env::consts::OS {
+ "macos" => "macos".to_string(),
+ "linux" => "linux".to_string(),
+ "windows" => "windows".to_string(),
+ other => format!("Other: {other}"),
}
}
+#[derive(Clone)]
+enum Action {
+ Execute(String),
+ Insert(String),
+ Print(String),
+ Cancel,
+}
+
fn emit_shell_result(action: Action, output_for_hook: bool) {
if output_for_hook {
match action {
@@ -741,8 +592,19 @@ fn emit_shell_result(action: Action, output_for_hook: bool) {
}
fn wait_for_login_confirmation() -> Result<bool> {
+ use crossterm::{
+ event::{self, Event, KeyCode},
+ terminal::{disable_raw_mode, enable_raw_mode},
+ };
+
enable_raw_mode().context("failed enabling raw mode for login prompt")?;
- let _guard = RawModeGuard;
+ struct Guard;
+ impl Drop for Guard {
+ fn drop(&mut self) {
+ let _ = disable_raw_mode();
+ }
+ }
+ let _guard = Guard;
loop {
let ev = event::read().context("failed to read login confirmation key")?;