commit - ab17466fc0927d8fb701d37f553e79fc4b4dec07
commit + a6cab55d086344d730abfafd10a1ca1d4fd18a13
blob - a92f7d0b2d7d92517d7c327c56d364640319e696
blob + 01e417b2c69dddd8b4df5205b2c4a317e01bd77b
--- README.md
+++ README.md
`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
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
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
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.
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)
}
}
- 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
//! so it can be continued later.
use std::io::{self, IsTerminal, Read};
+use std::path::PathBuf;
use anyhow::{Context, Result};
///
/// 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")?;
)
};
- 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(());
}
}
}
+/// 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
+//! 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();
+}