Commit Diff


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<String> {
     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<Self, Error> {
+        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<Option<String>, 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<SystemTime, Error> {
+    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::<String>::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::<ReasoningEffort>::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<PathBuf>,
+    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<PathBuf>,
+    show: bool,
+) -> Result<String> {
+    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<String>, 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();
+}