commit c8e3c5f06180d15bf331fbefe09eba169f617038 from: mtmn date: Mon Sep 7 18:56:22 2026 UTC add wip spinner into editor 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, } 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