Add Mistral plot-beat generation (Tools menu)

New Tools ▸ "Generate plot beats (Mistral)…" command: pick a novel
proposal markdown file (characters + loose plot summary) and have the
Mistral chat API lay it out as plot beats in the classic three-act
structure. The result opens in a floating window where it can be edited,
saved as a new "<proposal> — beats.md" workspace file, or copied.

- src/mistral.rs: pure-Rust ureq/rustls POST to /v1/chat/completions
  (self-contained; no C bindings). System prompt fixes the three-act
  markdown shape; friendly API-error surfacing. Unit tests for content
  parsing, empty/blank responses, error-message extraction, and the
  missing-key guard.
- config: mistral_api_key / mistral_model / mistral_base_url with
  effective-value helpers and defaults (mistral-large-latest,
  https://api.mistral.ai).
- app: background generation + poll (editor stays responsive), a
  Settings ▸ Mistral… window (key/model/base URL), and the results
  window with save/copy.
- Docs: README "Plot beats (Mistral)" section and help cheatsheet note.

Verified: cargo build --release clean, 56 tests pass (+6 Mistral),
ldd still only libc/libgcc/libm. Clippy: no new warnings in the added
code (the two extra vs. the old 3-warning baseline are toolchain-surfaced
in pre-existing app.rs code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDfaEsTgcn61n3wgP62DFF
This commit is contained in:
landon
2026-08-20 14:39:08 -05:00
parent 5bc2acad25
commit e5eadc08c1
6 changed files with 635 additions and 0 deletions
+295
View File
@@ -235,6 +235,17 @@ pub struct App {
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
/// Whether the Mistral settings window is open.
show_mistral_settings: bool,
/// In-flight background plot-beat generation, if any.
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
/// One-line status for the plot-beat generator.
beats_status: String,
/// The generated (and user-editable) plot beats; `Some` opens the results
/// window. Holds an empty string while generating or on error.
beats_output: Option<String>,
/// File stem of the proposal the beats came from, for the default save name.
beats_source_stem: Option<String>,
}
impl App {
@@ -293,6 +304,11 @@ impl App {
spell_rx: None,
spell_status: String::new(),
spell_menu: None,
show_mistral_settings: false,
beats_rx: None,
beats_status: String::new(),
beats_output: None,
beats_source_stem: None,
};
app.load_spell_dict();
app.open_workspace();
@@ -827,6 +843,259 @@ impl App {
}
}
/// Pick a novel-proposal markdown file and generate three-act plot beats
/// from it with the Mistral API, in the background. Opens the settings window
/// instead if no API key is configured yet.
fn generate_plot_beats(&mut self, ctx: &egui::Context) {
if self.beats_rx.is_some() {
return; // a generation is already running
}
if self.config.mistral_api_key.trim().is_empty() {
self.show_mistral_settings = true;
self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string();
self.beats_output = Some(String::new());
return;
}
let mut dialog = rfd::FileDialog::new()
.set_title("Choose a novel proposal (markdown)")
.add_filter("Markdown / text", &["md", "markdown", "txt"]);
if let Some(dir) = self.workspace().to_str() {
dialog = dialog.set_directory(dir);
}
let Some(path) = dialog.pick_file() else {
return; // cancelled
};
let proposal = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.beats_status = format!("Could not read {}: {e}", path.display());
self.beats_output = Some(String::new());
return;
}
};
if proposal.trim().is_empty() {
self.beats_status = "That file is empty — nothing to work from.".to_string();
self.beats_output = Some(String::new());
return;
}
self.beats_source_stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string);
let api_key = self.config.mistral_api_key.clone();
let model = self.config.mistral_effective_model();
let base = self.config.mistral_effective_base_url();
let (tx, rx) = std::sync::mpsc::channel();
self.beats_rx = Some(rx);
self.beats_status = "Generating plot beats…".to_string();
self.beats_output = Some(String::new());
let ctx = ctx.clone();
std::thread::spawn(move || {
let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal);
let _ = tx.send(result);
ctx.request_repaint();
});
}
/// Pick up a finished plot-beat generation and show it (or its error).
fn poll_beats(&mut self) {
let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(result) = received {
self.beats_rx = None;
match result {
Ok(beats) => {
self.beats_status = "Done — review, then save or copy.".to_string();
self.beats_output = Some(beats);
}
Err(e) => {
self.beats_status = format!("{e}");
// Keep the window open so the error stays visible.
self.beats_output.get_or_insert_with(String::new);
}
}
}
}
/// Write the generated beats to a new `<stem> — beats.md` file in the
/// workspace (dodging name collisions), add it to the list, and open it.
fn save_beats_as_file(&mut self, beats: &str) {
let stem = self
.beats_source_stem
.clone()
.unwrap_or_else(|| "plot".to_string());
let mut name = format!("{stem} — beats.md");
let mut n = 2;
while self.path_for(&name).exists() {
name = format!("{stem} — beats-{n}.md");
n += 1;
}
let path = self.path_for(&name);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim());
match std::fs::write(&path, contents) {
Ok(_) => {
if !self.files.contains(&name) {
self.files.push(name.clone());
self.persist_order();
}
if let Some(idx) = self.files.iter().position(|f| f == &name) {
self.selected = None; // force the buffer to reload
self.select(idx);
}
self.status = format!("Saved {name}");
self.beats_output = None; // close the window; the file is now open
self.beats_status.clear();
}
Err(e) => self.beats_status = format!("Save failed: {e}"),
}
}
/// Floating window presenting the generated plot beats, with save / copy.
fn beats_window(&mut self, ctx: &egui::Context) {
let mut open = true;
let mut close = false;
let mut save = false;
let running = self.beats_rx.is_some();
// Edit a local copy so the button row can borrow `self` mutably; persist
// any edits back into `beats_output` afterwards.
let mut text = self.beats_output.clone().unwrap_or_default();
egui::Window::new("✨ Plot beats (Mistral)")
.open(&mut open)
.resizable(true)
.default_width(560.0)
.default_height(520.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
if running {
ui.spinner();
}
if !self.beats_status.is_empty() {
ui.label(egui::RichText::new(&self.beats_status).weak());
}
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut text)
.desired_width(f32::INFINITY)
.desired_rows(20)
.font(egui::TextStyle::Monospace),
);
});
ui.separator();
ui.horizontal(|ui| {
let have = !text.trim().is_empty();
if ui
.add_enabled(have, egui::Button::new("💾 Save as new file"))
.clicked()
{
save = true;
}
if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() {
ui.output_mut(|o| o.copied_text = text.clone());
self.beats_status = "Copied to clipboard.".to_string();
}
if ui.button("Close").clicked() {
close = true;
}
});
});
// Persist edits made in the text box (unless we're about to close/save).
if self.beats_output.is_some() {
self.beats_output = Some(text.clone());
}
if save {
self.save_beats_as_file(&text);
} else if close || !open {
self.beats_output = None;
self.beats_status.clear();
}
}
/// Floating window for editing the Mistral API connection settings.
fn mistral_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_mistral_settings;
let mut close_clicked = false;
egui::Window::new("Mistral settings")
.open(&mut open)
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("mistral_settings_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("API key:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_api_key)
.password(true)
.hint_text("from console.mistral.ai")
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Model:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_model)
.hint_text(crate::mistral::DEFAULT_MODEL)
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Base URL:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_base_url)
.hint_text(crate::mistral::DEFAULT_BASE_URL)
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!(
"Endpoint: {}/v1/chat/completions",
self.config.mistral_effective_base_url()
))
.weak()
.monospace(),
);
ui.label(
egui::RichText::new(
"The key is stored in this app's config file in plain text. \
The proposal you choose is sent to Mistral to generate the beats.",
)
.small()
.weak(),
);
ui.separator();
if ui.button("Close").clicked() {
close_clicked = true;
}
if save_now {
self.config.save();
}
});
let now_open = open && !close_clicked;
if self.show_mistral_settings && !now_open {
self.config.save();
}
self.show_mistral_settings = now_open;
}
/// Poll for a finished background check and store its results.
fn poll_lt(&mut self) {
let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok());
@@ -1564,6 +1833,19 @@ impl App {
self.open_find(true);
}
});
ui.menu_button("Tools", |ui| {
let busy = self.beats_rx.is_some();
if ui
.add_enabled(
!busy,
egui::Button::new("✨ Generate plot beats (Mistral)…"),
)
.clicked()
{
ui.close_menu();
self.generate_plot_beats(ctx);
}
});
ui.menu_button("View", |ui| {
if ui
.checkbox(&mut self.config.show_preview, "Preview pane")
@@ -1579,6 +1861,10 @@ impl App {
ui.close_menu();
self.show_settings = true;
}
if ui.button("Mistral…").clicked() {
ui.close_menu();
self.show_mistral_settings = true;
}
});
ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() {
@@ -2388,6 +2674,7 @@ impl eframe::App for App {
self.poll_lt();
self.poll_settings_test();
self.poll_spell();
self.poll_beats();
self.maybe_start_spell_check(ctx);
self.menu_bar(ctx);
@@ -2427,6 +2714,14 @@ impl eframe::App for App {
self.settings_window(ctx);
}
if self.show_mistral_settings {
self.mistral_settings_window(ctx);
}
if self.beats_output.is_some() {
self.beats_window(ctx);
}
if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
}