thal 0.0.1

Reactive semantic runtime — molecules, reactions, and effect actors for building LLM-backed applications as dataflow programs.
Documentation
//! `reedline`-backed `PromptIo` impl. Replaces `StdinStdoutIo` as the default
//! in `ActorRegistry::with_builtins`. Tests still use `ScriptedPromptIo` via
//! `with_prompt_io`, unaffected.
//!
//! Features:
//!   - Up/Down history navigation, persisted to `~/.config/thal/history.txt`.
//!   - Vi/Emacs keybindings (default Emacs).
//!   - Slash-command interception: `/exit`, `/clear`, `/help` handled inline,
//!     loop until the user types a non-command input.
//!   - Multi-line continuation via trailing `\`.
//!   - Falls back to plain `read_line` when stdin isn't a TTY (so piped tests
//!     still work).

use super::terminal_prompt::{PromptIo, StdinStdoutIo};
use crate::Error;
use async_trait::async_trait;
use parking_lot::Mutex;
use reedline::{
    FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch,
    PromptHistorySearchStatus, Reedline, Signal,
};
use std::borrow::Cow;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;

/// Minimalist reedline prompt — renders just the user's question text and a
/// single-glyph indicator, no `〉` chevron or mode badge. Question text is
/// passed through verbatim so the `.thal` author controls the wording.
struct ThalPrompt {
    text: String,
}

impl Prompt for ThalPrompt {
    fn render_prompt_left(&self) -> Cow<'_, str> {
        Cow::Owned(self.text.clone())
    }
    fn render_prompt_right(&self) -> Cow<'_, str> {
        Cow::Borrowed("")
    }
    fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> {
        // Empty so no `>` is appended after the question text.
        Cow::Borrowed("")
    }
    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
        Cow::Borrowed("· ")
    }
    fn render_prompt_history_search_indicator(
        &self,
        history_search: PromptHistorySearch,
    ) -> Cow<'_, str> {
        let prefix = match history_search.status {
            PromptHistorySearchStatus::Passing => "",
            PromptHistorySearchStatus::Failing => "failing ",
        };
        Cow::Owned(format!("({prefix}reverse-search: {}) ", history_search.term))
    }
}

pub struct ReedlineIo {
    /// `None` when stdin isn't a TTY — we fall back to the plain reader so
    /// piped input (tests, scripts) still works without raw-mode failures.
    inner: Option<Arc<Mutex<ReedlineState>>>,
    fallback: StdinStdoutIo,
}

struct ReedlineState {
    editor: Reedline,
}

impl ReedlineIo {
    pub fn new() -> Self {
        let inner = if std::io::stdin().is_terminal() {
            let mut editor = Reedline::create();
            if let Some(path) = history_path() {
                if let Some(parent) = path.parent() {
                    let _ = std::fs::create_dir_all(parent);
                }
                if let Ok(history) = FileBackedHistory::with_file(1000, path) {
                    editor = editor.with_history(Box::new(history));
                }
            }
            Some(Arc::new(Mutex::new(ReedlineState { editor })))
        } else {
            None
        };
        Self {
            inner,
            fallback: StdinStdoutIo,
        }
    }
}

impl Default for ReedlineIo {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl PromptIo for ReedlineIo {
    async fn prompt(&self, question: &str) -> Result<String, Error> {
        let inner = match &self.inner {
            Some(i) => i.clone(),
            None => return self.fallback.prompt(question).await,
        };
        let question = question.to_string();
        let answer = tokio::task::spawn_blocking(move || -> Result<String, Error> {
            loop {
                let signal = {
                    let mut state = inner.lock();
                    let prompt = ThalPrompt {
                        text: question.clone(),
                    };
                    state
                        .editor
                        .read_line(&prompt)
                        .map_err(|e| Error::Runtime(format!("reedline: {e}")))?
                };
                match signal {
                    Signal::Success(line) => {
                        let trimmed = line.trim();
                        if let Some(rest) = trimmed.strip_prefix('/') {
                            match handle_slash_command(rest) {
                                SlashOutcome::Continue => continue,
                                SlashOutcome::ReturnInput(s) => return Ok(s),
                            }
                        }
                        return Ok(line);
                    }
                    Signal::CtrlC => {
                        eprintln!("(use /exit or Ctrl+D to quit)");
                        continue;
                    }
                    Signal::CtrlD => {
                        eprintln!();
                        std::process::exit(0);
                    }
                }
            }
        })
        .await
        .map_err(|e| Error::Runtime(format!("reedline task join: {e}")))??;
        Ok(answer)
    }
}

enum SlashOutcome {
    Continue,
    #[allow(dead_code)]
    ReturnInput(String),
}

fn handle_slash_command(rest: &str) -> SlashOutcome {
    let cmd = rest.split_whitespace().next().unwrap_or("");
    match cmd {
        "exit" | "quit" | "q" => {
            eprintln!("bye");
            std::process::exit(0);
        }
        "clear" | "cls" => {
            // ANSI clear screen + home cursor.
            print!("\x1b[2J\x1b[H");
            use std::io::Write;
            let _ = std::io::stdout().flush();
            SlashOutcome::Continue
        }
        "help" | "h" | "?" => {
            eprintln!("commands:");
            eprintln!("  /exit, /quit, /q   exit");
            eprintln!("  /clear, /cls       clear the screen");
            eprintln!("  /help, /h, /?      this help");
            SlashOutcome::Continue
        }
        other => {
            eprintln!("unknown command: /{other} (try /help)");
            SlashOutcome::Continue
        }
    }
}

fn history_path() -> Option<PathBuf> {
    if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
        return Some(PathBuf::from(d).join("thal").join("history.txt"));
    }
    if let Some(home) = std::env::var_os("HOME") {
        return Some(
            PathBuf::from(home)
                .join(".config")
                .join("thal")
                .join("history.txt"),
        );
    }
    None
}