commit a6cab55d086344d730abfafd10a1ca1d4fd18a13 from: mtmn date: Sun Sep 6 17:02:45 2026 UTC keep vim/nvim buffer open in compose mode commit - ab17466fc0927d8fb701d37f553e79fc4b4dec07 commit + a6cab55d086344d730abfafd10a1ca1d4fd18a13 blob - a92f7d0b2d7d92517d7c327c56d364640319e696 blob + 01e417b2c69dddd8b4df5205b2c4a317e01bd77b --- README.md +++ README.md @@ -179,8 +179,9 @@ $ echo "50 + 50" | alpaca --system "Solve the followin `alpaca compose` edits a prompt in `$VISUAL`, `$EDITOR`, or `vim`, sends the saved text, then writes the reply to standard output. Piped input fills the -buffer first. The editor runs on `/dev/tty`, so editor input and output stay -out of the pipeline. Saving an empty buffer sends nothing. +buffer first. The editor runs on the controlling terminal, so editor input and +output stay out of the pipeline. If the process has no controlling terminal, +the editor inherits `alpaca`'s streams. Saving an empty buffer sends nothing. ```sh # Write a prompt in the editor, send it, see the reply @@ -223,6 +224,13 @@ conversation as context, reusing the saved model, effo unless you override them on the command line. Save an empty buffer to leave the loop. +When the editor is `vim` or `neovim`, `-c` keeps one editor open for the +whole conversation. Writing the buffer sends the prompt, and the reply +replaces the buffer in place, so every turn happens in the same window. Quit +the editor to end the session; writing an empty buffer sends nothing. In this +mode the reply is written to standard output only when standard output is not +a terminal, since the editor owns the screen. + See `man/alpaca-compose.1`. --- blob - 78087cc46e81c8f13c7ea806cea11e3cf9e9f0f7 blob + cbb8ab6430c5acf40d674d3e48cba8c348e2598e --- man/alpaca-compose.1.scd +++ man/alpaca-compose.1.scd @@ -33,6 +33,15 @@ in the editor. It keeps prompting, sending the whole c each turn. The saved model, reasoning effort and system prompt are reused unless given on the command line. Save an empty buffer to leave the loop. +When the editor is *vim* or *neovim*, *-c* keeps one editor open for the whole +conversation instead of reopening it each turn. Writing the buffer sends it, +and the reply replaces the buffer in place, so every turn happens in the same +window. Quit the editor to end the session; writing an empty buffer sends +nothing. The buffer is reloaded through *autoread* and *checktime*, so a reply +that arrives while unsaved changes are pending waits for the next write. In +this mode the reply is only written to standard output when standard output is +not a terminal, since the editor owns the screen. + # OPTIONS *-m*, *--model* _model_ blob - 24abb4c683a5ab2b84310b724ab0e9fc552a9334 blob + 8a27cda418f83ba922a492655a304aa94e0dd1ab --- src/editor.rs +++ src/editor.rs @@ -7,13 +7,29 @@ use std::fs; use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, SystemTime}; use crate::Error; /// Editor used when neither `VISUAL` nor `EDITOR` is set const FALLBACK_EDITOR: &str = "vim"; +/// Editors that can host a whole conversation in one buffer +const LIVE_EDITORS: [&str; 4] = ["vim", "gvim", "nvim", "neovim"]; + +/// How often a live session looks for a write of its buffer +const POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Reload the buffer when alpaca replaces the file with a reply +/// +/// `checktime` only reloads a buffer with no unsaved changes, so a reply that +/// lands while the next prompt is being typed waits for the write instead of +/// discarding it. +const RELOAD_TIMER: &str = + "call timer_start(200, {-> execute('silent! checktime')}, {'repeat': -1})"; + /// Open `initial` in the user's editor and return the saved contents /// /// The temporary file is removed on every path, including editor failure. @@ -54,17 +70,31 @@ fn split(value: &str) -> Vec { value.split_whitespace().map(String::from).collect() } -/// Run the editor on `path`, wired to the controlling terminal -/// -/// Without a controlling terminal there is nothing better to attach the editor -/// to, so it inherits this process's own streams. +/// Run the editor on `path` and wait for it to exit fn run(path: &Path) -> Result<(), Error> { let words = command(); let (program, args) = words.split_first().expect("command is never empty"); let mut editor = Command::new(program); editor.args(args).arg(path); + attach_tty(&mut editor)?; + let status = editor + .status() + .map_err(|err| Error::Editor(format!("run {program}: {err}")))?; + + if status.success() { + Ok(()) + } else { + Err(Error::EditorExit(status.code().unwrap_or(1))) + } +} + +/// Wire a command to the controlling terminal +/// +/// Without a controlling terminal there is nothing better to attach the editor +/// to, so it inherits this process's own streams. +fn attach_tty(editor: &mut Command) -> Result<(), Error> { match fs::OpenOptions::new() .read(true) .write(true) @@ -84,22 +114,145 @@ fn run(path: &Path) -> Result<(), Error> { } } - let status = editor - .status() - .map_err(|err| Error::Editor(format!("run {program}: {err}")))?; + Ok(()) +} - if status.success() { +/// Whether the configured editor can host a live session +/// +/// Only vim and neovim are recognised: the session relies on `autoread` and +/// `checktime`, and on `-c` for setting them up. +#[must_use] +pub fn is_live() -> bool { + is_live_editor(&command()) +} + +fn is_live_editor(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| LIVE_EDITORS.contains(&name)) +} + +/// A vim or neovim instance that stays open across turns +/// +/// The editor holds one buffer for the whole conversation: writing it submits +/// the prompt, and the reply replaces the buffer in place. The session ends +/// when the editor exits. +pub struct Session { + file: tempfile::NamedTempFile, + editor: Child, + /// Modification time of the last buffer contents alpaca has handled + seen: SystemTime, +} + +impl Session { + /// Open `initial` in a live editor + /// + /// # Errors + /// + /// Returns [`Error::Editor`] when the editor cannot be started, and + /// [`Error::IO`] when the buffer file cannot be written. + /// + /// # Panics + /// + /// Panics if [`command`] returns no words, which cannot happen: it falls + /// back to [`FALLBACK_EDITOR`] when `VISUAL` and `EDITOR` are unset or + /// empty. + pub fn open(initial: &str) -> Result { + let mut file = tempfile::Builder::new() + .prefix("alpaca-") + .suffix(".md") + .tempfile() + .map_err(Error::IO)?; + file.write_all(initial.as_bytes())?; + file.flush()?; + + let words = command(); + let (program, args) = words.split_first().expect("command is never empty"); + + let mut editor = Command::new(program); + editor + .args(args) + .args(["-c", "set autoread"]) + .args(["-c", RELOAD_TIMER]) + .arg(file.path()); + attach_tty(&mut editor)?; + + let editor = editor + .spawn() + .map_err(|err| Error::Editor(format!("run {program}: {err}")))?; + + let seen = modified(file.path())?; + Ok(Self { file, editor, seen }) + } + + /// Wait for the next write of the buffer + /// + /// Returns `None` once the editor exits. A write of an empty buffer sends + /// nothing, so quitting the editor is what ends the session. + /// + /// # Errors + /// + /// Returns [`Error::EditorExit`] when the editor exits non-zero, and + /// [`Error::IO`] when the buffer cannot be read. + pub fn next_prompt(&mut self) -> Result, Error> { + loop { + let modified = modified(self.file.path())?; + if modified != self.seen { + self.seen = modified; + let prompt = fs::read_to_string(self.file.path())?; + if !prompt.trim().is_empty() { + return Ok(Some(prompt)); + } + } + + // Checked after the buffer, so a write immediately before quitting + // is still submitted. + if let Some(status) = self.editor.try_wait().map_err(Error::IO)? { + return if status.success() { + Ok(None) + } else { + Err(Error::EditorExit(status.code().unwrap_or(1))) + }; + } + + thread::sleep(POLL_INTERVAL); + } + } + + /// Replace the buffer with `reply`, ready to be edited into the next prompt + /// + /// # Errors + /// + /// Returns [`Error::IO`] when the buffer file cannot be written. + pub fn reply(&mut self, reply: &str) -> Result<(), Error> { + fs::write(self.file.path(), reply)?; + self.seen = modified(self.file.path())?; Ok(()) - } else { - Err(Error::EditorExit(status.code().unwrap_or(1))) } } +fn modified(path: &Path) -> Result { + Ok(fs::metadata(path)?.modified()?) +} + #[cfg(test)] mod test { use super::*; #[test] + fn recognises_vim_and_neovim() { + for name in ["vim", "nvim -u NONE", "/usr/bin/nvim", "neovim"] { + assert!(is_live_editor(&split(name)), "{name} hosts a live session"); + } + for name in ["emacs", "nano", "helix", "vi", "code -w"] { + assert!(!is_live_editor(&split(name)), "{name} does not"); + } + } + + #[test] fn splits_on_whitespace() { assert_eq!(split("emacsclient -nw"), vec!["emacsclient", "-nw"]); assert_eq!(split(" "), Vec::::new()); blob - ad7dfd7746ce6db96f34473d78e5921c3f24612b blob + 41d1f356ae7e5a9540c7bd8899a2a3fa0a6194a7 --- src/exec/compose.rs +++ src/exec/compose.rs @@ -4,6 +4,7 @@ //! so it can be continued later. use std::io::{self, IsTerminal, Read}; +use std::path::PathBuf; use anyhow::{Context, Result}; @@ -18,7 +19,7 @@ use crate::{Error, editor}; /// /// Returns an error if the editor fails, the request fails, or the /// conversation cannot be saved. -pub async fn exec(args: ComposeArgs) -> Result<()> { +pub async fn exec(mut args: ComposeArgs) -> Result<()> { let client = api::Client::new(args.api_key.clone(), super::base_url(args.base_url.clone())) .with_context(|| "failed to create http client")?; @@ -34,44 +35,28 @@ pub async fn exec(args: ComposeArgs) -> Result<()> { ) }; - transcript.model = resolve(args.model, &transcript.model, DEFAULT_MODEL.to_string()); + transcript.model = resolve( + args.model.take(), + &transcript.model, + DEFAULT_MODEL.to_string(), + ); transcript.effort = args .reasoning_effort .map_or(transcript.effort, Option::::from); - transcript.system = args.system.or(transcript.system); + transcript.system = args.system.take().or(transcript.system); + if args.continue_conversation && editor::is_live() { + return live(&client, &args, transcript, path, prefill).await; + } + loop { let prompt = editor::edit(&prefill)?; if prompt.is_empty() { return Ok(()); } - transcript.messages.push(Message::user(&prompt)); + let reply = turn(&client, &args, &mut transcript, &prompt, &mut path, true).await?; - let request = api::ChatRequest::builder() - .model(transcript.model.clone()) - .messages(transcript.request_messages()) - .temperature(args.temperature) - .timeout(args.timeout) - .think(transcript.effort) - .build() - .with_context(|| "failed to create request")?; - - let response = client - .create_response(&request) - .await - .with_context(|| "failed to fetch request")?; - - let reply = super::reply(&response)?.message.content.clone(); - transcript.messages.push(Message::assistant(&reply)); - - match &path { - Some(path) => transcript.save(path)?, - None => path = Some(transcript.create()?), - } - - super::show_response(io::stdout(), args.output_format, &response)?; - if !args.continue_conversation { return Ok(()); } @@ -79,6 +64,75 @@ pub async fn exec(args: ComposeArgs) -> Result<()> { } } +/// Continue the conversation inside a live vim or neovim session +/// +/// The editor stays open for the whole conversation: writing the buffer sends +/// it, and the reply replaces the buffer in place, so the next prompt is +/// edited in the same window. Quitting the editor ends the session. +async fn live( + client: &api::Client, + args: &ComposeArgs, + mut transcript: Transcript, + mut path: Option, + prefill: String, +) -> Result<()> { + // The editor owns the terminal, so replies are only written out when + // standard output goes somewhere else. In the editor they land in the + // buffer either way. + let show = !io::stdout().is_terminal(); + let mut session = editor::Session::open(&prefill)?; + + while let Some(prompt) = session.next_prompt()? { + let reply = turn(client, args, &mut transcript, &prompt, &mut path, show).await?; + session.reply(&reply)?; + } + + Ok(()) +} + +/// Send `prompt` with the conversation so far, then save and show the reply +/// +/// `path` is the transcript's file, created on the first saved turn. The reply +/// is written to standard output when `show` is set. +async fn turn( + client: &api::Client, + args: &ComposeArgs, + transcript: &mut Transcript, + prompt: &str, + path: &mut Option, + show: bool, +) -> Result { + transcript.messages.push(Message::user(prompt)); + + let request = api::ChatRequest::builder() + .model(transcript.model.clone()) + .messages(transcript.request_messages()) + .temperature(args.temperature) + .timeout(args.timeout) + .think(transcript.effort) + .build() + .with_context(|| "failed to create request")?; + + let response = client + .create_response(&request) + .await + .with_context(|| "failed to fetch request")?; + + let reply = super::reply(&response)?.message.content.clone(); + transcript.messages.push(Message::assistant(&reply)); + + match path { + Some(path) => transcript.save(path)?, + None => *path = Some(transcript.create()?), + } + + if show { + super::show_response(io::stdout(), args.output_format, &response)?; + } + + Ok(reply) +} + /// Command line value, else the saved value, else the default fn resolve(given: Option, saved: &str, default: String) -> String { given.unwrap_or_else(|| { blob - /dev/null blob + 129567edf4fd285888e49036219125e18279ef3b (mode 644) --- /dev/null +++ tests/compose.rs @@ -0,0 +1,121 @@ +//! Integration tests for compose subcommand + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +/// A canned successful native `/api/chat` response body +fn ok_body(content: &str) -> String { + format!( + r#"{{ + "model": "gpt-4o-mini", + "created_at": "2025-10-17T23:14:07.414671Z", + "message": {{ "role": "assistant", "content": "{content}" }}, + "done": true, + "done_reason": "stop", + "prompt_eval_count": 8, + "eval_count": 9 + }}"# + ) +} + +/// A saved conversation for `--continue` to pick up +fn saved_transcript(data_home: &TempDir) { + let dir = data_home.path().join("alpaca"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("1-0.json"), + r#"{ + "version": 1, + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "earlier prompt"}, + {"role": "assistant", "content": "earlier reply"} + ] + }"#, + ) + .unwrap(); +} + +/// A stand-in for neovim that writes the buffer twice, then quits +/// +/// It checks the prefill and the reply that alpaca writes back, so a session +/// that never reloads the buffer fails the test. +fn fake_nvim(dir: &TempDir) -> std::path::PathBuf { + let path = dir.path().join("nvim"); + fs::write( + &path, + r#"#!/bin/sh +set -e +for buffer; do :; done +test "$(cat "$buffer")" = "earlier reply" +printf 'first prompt' > "$buffer" +tries=0 +while [ "$(cat "$buffer")" != "REPLY ONE" ]; do + tries=$((tries + 1)) + [ "$tries" -lt 100 ] || exit 2 + sleep 0.1 +done +printf 'second prompt' > "$buffer" +tries=0 +while [ "$(cat "$buffer")" != "REPLY TWO" ]; do + tries=$((tries + 1)) + [ "$tries" -lt 100 ] || exit 3 + sleep 0.1 +done +"#, + ) + .unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +#[test] +fn compose_continue_runs_turns_in_one_vim_session() { + let mut server = mockito::Server::new(); + let first = server + .mock("POST", "/api/chat") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("earlier prompt".into()), + mockito::Matcher::Regex("first prompt".into()), + ])) + .with_header("content-type", "application/json") + .with_body(ok_body("REPLY ONE")) + .create(); + let second = server + .mock("POST", "/api/chat") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("earlier prompt".into()), + mockito::Matcher::Regex("first prompt".into()), + mockito::Matcher::Regex("REPLY ONE".into()), + mockito::Matcher::Regex("second prompt".into()), + ])) + .with_header("content-type", "application/json") + .with_body(ok_body("REPLY TWO")) + .create(); + + let config_home = TempDir::new().unwrap(); + let data_home = TempDir::new().unwrap(); + let editor_dir = TempDir::new().unwrap(); + saved_transcript(&data_home); + let editor = fake_nvim(&editor_dir); + + // One editor invocation covers both turns: the fake editor exits only + // after seeing each reply land in its buffer. + Command::cargo_bin("alpaca") + .unwrap() + .args(["compose", "--continue"]) + .env("XDG_CONFIG_HOME", config_home.path()) + .env("XDG_DATA_HOME", data_home.path()) + .env("VISUAL", editor.to_str().unwrap()) + .env("API_ENDPOINT", server.url()) + .env("API_KEY", "ABCDE") + .assert() + .success() + .stdout(predicate::str::contains("REPLY ONE").and(predicate::str::contains("REPLY TWO"))); + + first.assert(); + second.assert(); +}