Commit Diff


commit - bb6838b9c104b4902178e2dc8a89be358400c386
commit + c8e3c5f06180d15bf331fbefe09eba169f617038
blob - 85e0ebdee9ef179921842f13dd98b45c65e907ed
blob + b26c5beeb1c1d1bbe9b6880d1fd4ee79f0b9560a
--- Cargo.lock
+++ Cargo.lock
@@ -13,7 +13,7 @@ dependencies = [
 
 [[package]]
 name = "alpaca"
-version = "0.7.3"
+version = "0.7.4"
 dependencies = [
  "anyhow",
  "assert_cmd",
@@ -343,6 +343,12 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 
 [[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
+[[package]]
 name = "cpufeatures"
 version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -467,11 +473,17 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451
 
 [[package]]
 name = "encoding_rs"
-version = "0.8.35"
+version = "0.8.40"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e"
 dependencies = [
  "cfg-if",
+ "core_detect",
+ "multiversion",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
 ]
 
 [[package]]
@@ -1102,6 +1114,34 @@ dependencies = [
 ]
 
 [[package]]
+name = "multiversion"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
+dependencies = [
+ "multiversion-macros",
+ "target-features",
+]
+
+[[package]]
+name = "multiversion-macros"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "target-features",
+]
+
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
+[[package]]
 name = "normalize-line-endings"
 version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1488,9 +1528,9 @@ dependencies = [
 
 [[package]]
 name = "rustls"
-version = "0.23.43"
+version = "0.23.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
 dependencies = [
  "aws-lc-rs",
  "once_cell",
@@ -1832,6 +1872,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "target-features"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
+
+[[package]]
 name = "tempfile"
 version = "3.27.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
blob - 302a294a01cffb9b332fd54a9b6679776da1b21e
blob + 1674003b0b53b2f8f03894b56b03481b3833e42b
--- Cargo.toml
+++ Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "alpaca"
 authors = ["leoshimo", "mtmn"]
-version = "0.7.3"
+version = "0.7.4"
 edition = "2024"
 description = "Unix native interface for LLMs"
 repository = "https://github.com/leoshimo/cogni"
blob - 10c9a0070028473452cdc4b04d9337df264db448
blob + 65e2267280ea6945baa53e5dcb28b27e683bb5ce
--- README.md
+++ README.md
@@ -219,7 +219,7 @@ temporary file. When the editor exits nonzero, `alpaca
 status.
 
 `alpaca` saves each successful request as a JSON transcript in
-`$XDG_DATA_HOME/alpaca` or `~/.local/share/alpaca`. Files are written with mode
+`$XDG_DATA_HOME/alpaca` or `~/.local/share/alpaca`. `alpaca` writes files with mode
 `0600` in a directory with mode `0700`. Each turn sends the whole conversation
 as context.
 
@@ -233,6 +233,11 @@ your next write. In this mode, `alpaca` only writes th
 output when standard output is not a terminal, because the editor owns the
 screen.
 
+In neovim, a spinner shows while `alpaca` waits for the reply. It appears when
+the session starts and again after you save a non-empty buffer, which clears
+the buffer display so only the spinner shows until the reply arrives. Classic
+vim gets no spinner.
+
 See `man/alpaca-compose.1`.
 
 ---
@@ -257,8 +262,8 @@ $ alpaca quota -p ollama       # Ollama Cloud
 ```
 
 A provider with no usable credential, or a failed request or response, is left
-off the screen. The rest are still drawn, and alpaca exits successfully. With
-`-p`, failures for that provider are reported instead. Without `-p`,
+off the screen. `alpaca` still draws the rest and exits successfully. With
+`-p`, `alpaca` reports failures for that provider instead. Without `-p`,
 `--apikey` and `--base-url` apply to all providers. `--raw` requires `-p`.
 
 Each provider has its own default credential and base URL:
blob - adece4abc166a2b72f7d505d0eabb091738876f0
blob + be87851ab36a15e4c8b18a41e58b8a7981938ae5
--- man/alpaca-compose.1.scd
+++ man/alpaca-compose.1.scd
@@ -27,9 +27,9 @@ inherits the process's streams. Saving an empty buffer
 the session with status 0.
 
 Each successful request is saved as a JSON transcript in _$XDG_DATA_HOME/alpaca_,
-or _~/.local/share/alpaca_ when that variable is unset. Transcripts are written
-with mode 0600 in a directory with mode 0700. Each turn sends the whole
-conversation as context.
+or _~/.local/share/alpaca_ when that variable is unset. *alpaca* writes the
+transcripts with mode 0600 in a directory with mode 0700. Each turn sends the
+whole conversation as context.
 
 When the editor is *vim* or *neovim*, *alpaca* keeps one editor open for the
 whole conversation instead of reopening it each turn. Writing the buffer
@@ -41,6 +41,11 @@ waits for your next write. In this mode, *alpaca* writ
 standard output only when standard output is not a terminal, since the
 editor owns the screen.
 
+In neovim, a spinner shows while *alpaca* waits for the reply. It appears
+when the session starts and again after you save a non-empty buffer. Saving
+a non-empty buffer clears the buffer display, so only the spinner shows
+until the reply arrives and reloads the buffer. Classic vim gets no spinner.
+
 # Options
 
 *-m*, *--model* _model_
blob - 8d9ba808d72cf18d4a7926a71940422e02043b75
blob + 726014e255bd792b5c85c6cf6a7146733e21815c
--- src/editor.rs
+++ src/editor.rs
@@ -30,6 +30,19 @@ const POLL_INTERVAL: Duration = Duration::from_millis(
 const RELOAD_TIMER: &str =
     "call timer_start(200, {-> execute('silent! checktime')}, {'repeat': -1})";
 
+/// Neovim Lua that shows the clanking spinner
+///
+/// The spinner is visible from the start. Saving a non-empty buffer submits
+/// the prompt and wipes the buffer display, so only the spinner shows while
+/// the reply is generated. When the reply lands, `autoread` reloads the
+/// buffer and the spinner stops. Buffer-only edits never touch the file, so
+/// alpaca still sees the saved prompt on disk.
+///
+/// The Lua lives in `editor/spinner.lua` and is compiled in with
+/// [`include_str!`]; `Session::open` writes it to a temporary `.lua` file
+/// because neovim loads it with `luafile`.
+const SPINNER_LUA: &str = include_str!("editor/spinner.lua");
+
 /// Open `initial` in the user's editor and return the saved contents
 ///
 /// The temporary file is removed on every path, including editor failure.
@@ -135,6 +148,16 @@ fn is_live_editor(words: &[String]) -> bool {
         .is_some_and(|name| LIVE_EDITORS.contains(&name))
 }
 
+/// Whether the editor is neovim, which can run the spinner Lua
+fn is_nvim(words: &[String]) -> bool {
+    let program = words.first().expect("command is never empty");
+
+    Path::new(program)
+        .file_stem()
+        .and_then(|name| name.to_str())
+        .is_some_and(|name| name == "nvim" || name == "neovim")
+}
+
 /// A vim or neovim instance that stays open across turns
 ///
 /// The editor holds one buffer for the whole conversation. Writing it
@@ -145,6 +168,9 @@ pub struct Session {
     editor: Child,
     /// Modification time of the last buffer contents alpaca has handled
     seen: SystemTime,
+    /// Neovim-only spinner Lua, kept alive for the session's lifetime
+    #[allow(dead_code)]
+    spinner: Option<tempfile::NamedTempFile>,
 }
 
 impl Session {
@@ -176,8 +202,25 @@ impl Session {
         editor
             .args(args)
             .args(["-c", "set autoread"])
-            .args(["-c", RELOAD_TIMER])
-            .arg(file.path());
+            .args(["-c", RELOAD_TIMER]);
+
+        // The spinner needs neovim's Lua runtime, so classic vim skips it.
+        let spinner = if is_nvim(&words) {
+            let spinner = tempfile::Builder::new()
+                .prefix("alpaca-spinner-")
+                .suffix(".lua")
+                .tempfile()
+                .map_err(Error::IO)?;
+            fs::write(spinner.path(), SPINNER_LUA)?;
+            editor
+                .arg("-c")
+                .arg(format!("luafile {}", spinner.path().display()));
+            Some(spinner)
+        } else {
+            None
+        };
+
+        editor.arg(file.path());
         attach_tty(&mut editor)?;
 
         let editor = editor
@@ -185,7 +228,12 @@ impl Session {
             .map_err(|err| Error::Editor(format!("run {program}: {err}")))?;
 
         let seen = modified(file.path())?;
-        Ok(Self { file, editor, seen })
+        Ok(Self {
+            file,
+            editor,
+            seen,
+            spinner,
+        })
     }
 
     /// Wait for the next write of the buffer
blob - /dev/null
blob + f89326a1df5864724d0c59f7ad8b14c1d791d6c6 (mode 644)
--- /dev/null
+++ src/editor/spinner.lua
@@ -0,0 +1,85 @@
+-- alpaca compose spinner: clanks while the reply is generated
+local left = { string.rep("\\", 3), string.rep("/", 3) }
+local message = "when in doubt clank it like its 1966"
+local width = #left[1] + 1 + #message + 1 + #left[2]
+local main = vim.api.nvim_get_current_buf()
+local buf, win, timer
+
+local function draw(i)
+  vim.api.nvim_buf_set_lines(buf, 0, -1, false, { left[i] .. " " .. message .. " " .. left[3 - i] })
+end
+
+local function start()
+  if win and vim.api.nvim_win_is_valid(win) then
+    return
+  end
+  if timer then
+    timer:stop()
+  end
+  buf = vim.api.nvim_create_buf(false, true)
+  draw(1)
+  win = vim.api.nvim_open_win(buf, false, {
+    relative = "editor",
+    row = 1,
+    col = math.max(0, math.floor((vim.o.columns - width) / 2)),
+    width = width,
+    height = 1,
+    style = "minimal",
+    border = "none",
+    focusable = false,
+  })
+  vim.api.nvim_set_option_value("winblend", 100, { win = win })
+  vim.api.nvim_set_hl(0, "SpinnerText", { bold = true, default = true })
+  vim.api.nvim_set_option_value("winhighlight", "NormalFloat:SpinnerText", { win = win })
+  timer = vim.uv.new_timer()
+  local t = timer
+  local i = 0
+  t:start(0, 120, vim.schedule_wrap(function()
+    if not win or not vim.api.nvim_win_is_valid(win) then
+      t:stop()
+      return
+    end
+    i = (i % 2) + 1
+    draw(i)
+  end))
+end
+
+local function stop()
+  if timer then
+    timer:stop()
+  end
+  if win and vim.api.nvim_win_is_valid(win) then
+    vim.api.nvim_win_close(win, true)
+  end
+  win, timer = nil, nil
+end
+
+-- visible from the start
+start()
+
+-- saving a non-empty buffer submits it: wipe the display and spin until the
+-- reply lands
+vim.api.nvim_create_autocmd("BufWritePost", {
+  buffer = main,
+  callback = function()
+    local prompt = false
+    for _, line in ipairs(vim.api.nvim_buf_get_lines(main, 0, -1, false)) do
+      if line:match("%S") then
+        prompt = true
+        break
+      end
+    end
+    if not prompt then
+      return
+    end
+    vim.api.nvim_buf_set_lines(main, 0, -1, false, { "" })
+    vim.api.nvim_set_option_value("modified", false, { buf = main })
+    start()
+  end,
+})
+
+-- the reply replaced the buffer on disk: stop clanking
+vim.api.nvim_create_autocmd("FileChangedShellPost", {
+  buffer = main,
+  callback = stop,
+})
\ No newline at end of file