use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use chrono::Local;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use tokio::sync::mpsc::UnboundedReceiver;
use tui_textarea::TextArea;
use crate::ai::AiClient;
use crate::ai::prompts::{Prompt, PromptLibrary};
use crate::ai::stream::{StreamMsg, spawn_chat_stream};
use crate::config::{Config, DEFAULT_PROMPTS};
use crate::prompts_tui::backup::{self, BackupEntry};
use crate::tui::input::TextInput;
pub fn run(project: &Path) -> Result<()> {
let prompts_path = project.join("prompts.hjson");
let app = App::load(project.to_path_buf(), &prompts_path)?;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = event_loop(&mut terminal, app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
result
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
Editor,
AiPrompt,
}
impl Focus {
fn next(self) -> Self {
match self {
Self::List => Self::Editor,
Self::Editor => Self::AiPrompt,
Self::AiPrompt => Self::List,
}
}
fn prev(self) -> Self {
match self {
Self::List => Self::AiPrompt,
Self::Editor => Self::List,
Self::AiPrompt => Self::Editor,
}
}
fn label(self) -> &'static str {
match self {
Self::List => "list",
Self::Editor => "editor",
Self::AiPrompt => "ai prompt",
}
}
}
enum Modal {
None,
Help { body: String },
SaveConfirm { summary: SaveSummary },
Saved { message: String },
AddPrompt { buffer: String, error: Option<String> },
DeletePromptConfirm { name: String },
DiscardConfirm { unsaved: usize },
Rollback { entries: Vec<BackupEntry>, cursor: usize },
RollbackDelete { entry: BackupEntry },
RollbackPreview { entry: BackupEntry, body: String, scroll: usize },
}
#[derive(Debug, Clone)]
struct SaveSummary {
added: Vec<String>,
modified: Vec<String>,
removed: Vec<String>,
}
impl SaveSummary {
fn total(&self) -> usize {
self.added.len() + self.modified.len() + self.removed.len()
}
}
struct App {
project_root: PathBuf,
prompts_path: PathBuf,
library: PromptLibrary,
cursor: usize,
list_scroll: usize,
editor: TextArea<'static>,
dirty: HashSet<String>,
added: HashSet<String>,
removed: HashSet<String>,
baseline: std::collections::HashMap<String, String>,
first_launch: bool,
loaded_from_defaults: bool,
focus: Focus,
modal: Modal,
status: String,
ai: Option<AiRuntime>,
ai_input: TextInput,
ai_history: Vec<String>,
ai_history_cursor: Option<usize>,
last_send: Option<Send>,
inference: Option<Inference>,
meta_pending: bool,
}
#[derive(Clone)]
struct AiRuntime {
client: AiClient,
model: String,
provider: String,
}
#[derive(Debug)]
pub(super) struct Send {
pub prompt_name: Option<String>,
pub template_under_review: String,
pub analysis_request: String,
pub response: String,
pub started_at: Instant,
pub duration: Option<Duration>,
pub failed: bool,
}
pub(super) struct Inference {
rx: UnboundedReceiver<StreamMsg>,
#[allow(dead_code)]
started_at: Instant,
}
impl App {
fn load(project_root: PathBuf, prompts_path: &Path) -> Result<Self> {
let (library, loaded_from_defaults) = if prompts_path.exists() {
let lib = PromptLibrary::load(prompts_path)
.with_context(|| format!("load {}", prompts_path.display()))?;
(lib, false)
} else {
let lib: PromptLibrary = serde_hjson::from_str(DEFAULT_PROMPTS)
.context("parse embedded DEFAULT_PROMPTS")?;
(lib, true)
};
let baseline = build_baseline(&library);
let mut added: HashSet<String> = HashSet::new();
if loaded_from_defaults {
for p in &library.prompts {
added.insert(p.name.clone());
}
}
let editor = build_editor(library.prompts.first());
let status = if loaded_from_defaults {
format!(
"{} missing · {} embedded defaults staged · Ctrl+S to write",
prompts_path.display(),
library.prompts.len(),
)
} else {
format!(
"{} loaded · {} prompts",
prompts_path.display(),
library.prompts.len(),
)
};
let ai = build_ai_runtime(&project_root);
let status = match (&ai, status.as_str()) {
(Some(rt), s) => {
if loaded_from_defaults {
format!("{s} · LLM: {} · {}", rt.provider, rt.model)
} else {
format!("{s} · LLM: {} · {}", rt.provider, rt.model)
}
}
(None, s) => format!(
"{s} · LLM: (not configured — send is inert)",
),
};
Ok(Self {
project_root,
prompts_path: prompts_path.to_path_buf(),
library,
cursor: 0,
list_scroll: 0,
editor,
dirty: HashSet::new(),
added,
removed: HashSet::new(),
baseline,
first_launch: true,
loaded_from_defaults,
focus: Focus::List,
modal: Modal::None,
status,
ai,
ai_input: TextInput::new(),
ai_history: Vec::new(),
ai_history_cursor: None,
last_send: None,
inference: None,
meta_pending: false,
})
}
fn current_prompt(&self) -> Option<&Prompt> {
self.library.prompts.get(self.cursor)
}
fn stash_editor(&mut self) {
let Some(prompt) = self.library.prompts.get_mut(self.cursor) else {
return;
};
let body = self.editor.lines().join("\n");
if body == prompt.template {
return;
}
prompt.template = body;
let baseline = self.baseline.get(&prompt.name).cloned();
let name = prompt.name.clone();
let _ = prompt;
match baseline {
Some(base) if base == self.library.prompts[self.cursor].template => {
self.dirty.remove(&name);
}
_ => {
self.dirty.insert(name);
}
}
}
fn reload_editor(&mut self) {
self.editor = build_editor(self.library.prompts.get(self.cursor));
}
fn has_unsaved(&self) -> bool {
!self.dirty.is_empty()
|| !self.added.is_empty()
|| !self.removed.is_empty()
|| self.loaded_from_defaults
}
fn unsaved_count(&self) -> usize {
self.dirty.len() + self.added.len() + self.removed.len()
}
fn save_summary(&self) -> SaveSummary {
let mut added: Vec<String> = self.added.iter().cloned().collect();
added.sort();
let mut removed: Vec<String> = self.removed.iter().cloned().collect();
removed.sort();
let mut modified: Vec<String> = self
.dirty
.iter()
.filter(|name| !self.added.contains(*name))
.cloned()
.collect();
modified.sort();
SaveSummary {
added,
modified,
removed,
}
}
}
fn build_ai_runtime(project_root: &Path) -> Option<AiRuntime> {
let cfg_path = project_root.join("inkhaven.hjson");
let cfg = if cfg_path.exists() {
Config::load(&cfg_path).ok()?
} else {
Config::default()
};
let client = AiClient::from_config(&cfg.llm).ok()?;
let (model, _env) = client.resolve_provider(&cfg.llm, None).ok()?;
let model = model.to_string();
let provider = client.default_provider.clone();
Some(AiRuntime {
client,
model,
provider,
})
}
const ANALYSIS_SYSTEM_PROMPT: &str = "\
You are a prompt-engineering reviewer. The user is editing prompt \
templates that another LLM will execute later, and they're asking \
you to analyze, critique, or improve their drafts.
Templates may contain placeholders like `{{selection}}` and \
`{{context}}` — these are substituted at runtime by the inkhaven \
editor (with the user's selected prose and surrounding hierarchical \
context, respectively). Do NOT try to execute the template yourself; \
review it as a piece of prompt-engineering work.
Be specific. Quote phrases from the template when you critique them. \
Suggest concrete improvements. When the user asks a yes/no question \
about the template, answer it directly first, then justify.";
const DEFAULT_ANALYSIS_REQUEST: &str = "\
Critique this prompt template. Identify its strengths and weaknesses, \
comment on whether the placeholders are used effectively, and suggest \
one or two concrete improvements.";
fn build_analysis_request(template_body: &str, instruction: &str) -> String {
let instruction = if instruction.trim().is_empty() {
DEFAULT_ANALYSIS_REQUEST
} else {
instruction.trim()
};
format!(
"--- PROMPT TEMPLATE UNDER REVIEW ---\n\
{template_body}\n\
--- END TEMPLATE ---\n\
\n\
Analysis request:\n\
{instruction}",
)
}
fn build_baseline(library: &PromptLibrary) -> std::collections::HashMap<String, String> {
library
.prompts
.iter()
.map(|p| (p.name.clone(), p.template.clone()))
.collect()
}
fn build_editor(prompt: Option<&Prompt>) -> TextArea<'static> {
let lines: Vec<String> = match prompt {
Some(p) if !p.template.is_empty() => {
p.template.lines().map(|s| s.to_string()).collect()
}
Some(_) => vec![String::new()],
None => vec![String::new()],
};
TextArea::new(lines)
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: App,
) -> Result<()> {
loop {
pump_inference(&mut app);
terminal.draw(|f| render(f, &mut app))?;
let poll_ms = if app.inference.is_some() { 80 } else { 250 };
if event::poll(Duration::from_millis(poll_ms))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if handle_key(&mut app, key)? {
return Ok(());
}
}
}
}
}
}
fn handle_key(app: &mut App, key: KeyEvent) -> Result<bool> {
if app.meta_pending {
app.meta_pending = false;
match key.code {
KeyCode::Char('g') | KeyCode::Char('G') => {
insert_ai_response_into_editor(app);
app.focus = Focus::Editor;
}
KeyCode::Esc => {
app.status = "meta: cancelled".into();
}
other => {
app.status = format!(
"meta: unknown chord (got {other:?}) — Ctrl+B G to insert AI response",
);
}
}
return Ok(false);
}
if matches!(app.modal, Modal::None)
&& key.code == KeyCode::Char('b')
&& key.modifiers == KeyModifiers::CONTROL
{
app.meta_pending = true;
app.status = "META — next key is a chord suffix · Esc cancels".into();
return Ok(false);
}
if matches!(
app.modal,
Modal::Help { .. } | Modal::Saved { .. }
) {
app.modal = Modal::None;
app.first_launch = false;
return Ok(false);
}
if matches!(app.modal, Modal::SaveConfirm { .. }) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
let outcome = perform_save(app);
app.modal = match outcome {
Ok(msg) => Modal::Saved { message: msg },
Err(e) => Modal::Saved {
message: format!("save FAILED: {e:#}"),
},
};
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
app.modal = Modal::None;
app.status = "save cancelled".into();
}
_ => {}
}
return Ok(false);
}
if let Modal::AddPrompt { buffer, error } = &mut app.modal {
match key.code {
KeyCode::Esc => {
app.modal = Modal::None;
app.status = "add prompt: cancelled".into();
}
KeyCode::Enter => {
let name = buffer.trim().to_string();
if name.is_empty() {
*error = Some("name is required".into());
} else if !is_valid_prompt_name(&name) {
*error = Some(
"name must start with a letter / `_` and contain only letters / digits / `_` / `-`"
.into(),
);
} else if app
.library
.prompts
.iter()
.any(|p| p.name == name)
{
*error = Some(format!("`{name}` already exists"));
} else {
let new_prompt = Prompt {
name: name.clone(),
description: String::new(),
template: String::new(),
language: None,
};
app.library.prompts.push(new_prompt);
app.added.insert(name.clone());
app.dirty.insert(name.clone());
app.library.prompts.sort_by(|a, b| a.name.cmp(&b.name));
if let Some(idx) =
app.library.prompts.iter().position(|p| p.name == name)
{
app.cursor = idx;
}
app.reload_editor();
app.focus = Focus::Editor;
app.modal = Modal::None;
app.status =
format!("staged new prompt `{name}` — Ctrl+S to commit");
}
}
KeyCode::Backspace => {
buffer.pop();
*error = None;
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
buffer.push(c);
*error = None;
}
_ => {}
}
return Ok(false);
}
if let Modal::DeletePromptConfirm { name } = &app.modal {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
let name = name.clone();
if app.added.contains(&name) {
app.library.prompts.retain(|p| p.name != name);
app.added.remove(&name);
app.dirty.remove(&name);
if app.cursor >= app.library.prompts.len()
&& app.cursor > 0
{
app.cursor -= 1;
}
app.reload_editor();
app.status = format!("dropped unsaved prompt `{name}`");
} else {
app.removed.insert(name.clone());
app.status =
format!("staged deletion of `{name}` — Ctrl+S to commit");
}
app.modal = Modal::None;
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
app.modal = Modal::None;
}
_ => {}
}
return Ok(false);
}
if matches!(app.modal, Modal::DiscardConfirm { .. }) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
return Ok(true);
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
app.modal = Modal::None;
}
_ => {}
}
return Ok(false);
}
if let Modal::Rollback { entries, cursor } = &mut app.modal {
match key.code {
KeyCode::Esc => {
app.modal = Modal::None;
}
KeyCode::Up => {
if *cursor > 0 {
*cursor -= 1;
}
}
KeyCode::Down => {
if *cursor + 1 < entries.len() {
*cursor += 1;
}
}
KeyCode::PageUp => {
*cursor = cursor.saturating_sub(5);
}
KeyCode::PageDown => {
*cursor = (*cursor + 5).min(entries.len().saturating_sub(1));
}
KeyCode::Home => {
*cursor = 0;
}
KeyCode::End => {
*cursor = entries.len().saturating_sub(1);
}
KeyCode::Enter => {
let Some(entry) = entries.get(*cursor).cloned() else {
app.modal = Modal::None;
return Ok(false);
};
let outcome = stage_rollback(app, &entry);
match outcome {
Ok(count) => {
app.modal = Modal::None;
app.status = format!(
"rollback staged {count} change{} from {} — Ctrl+S to commit",
if count == 1 { "" } else { "s" },
entry.filename,
);
}
Err(e) => {
app.status = format!("rollback failed: {e:#}");
}
}
}
KeyCode::Char('v') | KeyCode::Char('V') => {
let Some(entry) = entries.get(*cursor).cloned() else {
return Ok(false);
};
match backup::read(&entry) {
Ok(body) => {
app.modal = Modal::RollbackPreview {
entry,
body,
scroll: 0,
};
}
Err(e) => {
app.status = format!("preview failed: {e:#}");
}
}
}
KeyCode::Char('d') | KeyCode::Char('D') => {
let Some(entry) = entries.get(*cursor).cloned() else {
return Ok(false);
};
app.modal = Modal::RollbackDelete { entry };
}
_ => {}
}
return Ok(false);
}
if let Modal::RollbackDelete { entry } = &app.modal {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
let entry = entry.clone();
match backup::delete(&entry) {
Ok(()) => {
app.status =
format!("deleted backup {}", entry.filename);
}
Err(e) => {
app.status = format!("delete failed: {e:#}");
}
}
match backup::list(&app.project_root) {
Ok(es) if !es.is_empty() => {
app.modal = Modal::Rollback {
entries: es,
cursor: 0,
};
}
_ => {
app.modal = Modal::None;
}
}
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
match backup::list(&app.project_root) {
Ok(es) if !es.is_empty() => {
app.modal = Modal::Rollback {
entries: es,
cursor: 0,
};
}
_ => {
app.modal = Modal::None;
}
}
}
_ => {}
}
return Ok(false);
}
if let Modal::RollbackPreview { body, scroll, .. } = &mut app.modal {
let total = body.lines().count();
match key.code {
KeyCode::Esc => {
match backup::list(&app.project_root) {
Ok(es) if !es.is_empty() => {
app.modal = Modal::Rollback {
entries: es,
cursor: 0,
};
}
_ => {
app.modal = Modal::None;
}
}
}
KeyCode::Up => *scroll = scroll.saturating_sub(1),
KeyCode::Down => {
if *scroll + 1 < total {
*scroll += 1;
}
}
KeyCode::PageUp => *scroll = scroll.saturating_sub(20),
KeyCode::PageDown => {
*scroll = (*scroll + 20).min(total.saturating_sub(1))
}
KeyCode::Home => *scroll = 0,
KeyCode::End => *scroll = total.saturating_sub(1),
_ => {}
}
return Ok(false);
}
if key.code == KeyCode::Char('q')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
if app.has_unsaved() {
app.modal = Modal::DiscardConfirm {
unsaved: app.unsaved_count(),
};
return Ok(false);
}
return Ok(true);
}
if key.code == KeyCode::Esc {
if app.has_unsaved() {
app.modal = Modal::DiscardConfirm {
unsaved: app.unsaved_count(),
};
return Ok(false);
}
return Ok(true);
}
if key.code == KeyCode::Char('s')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
app.stash_editor();
if !app.has_unsaved() {
app.status = "nothing to save".into();
return Ok(false);
}
let summary = app.save_summary();
app.modal = Modal::SaveConfirm { summary };
return Ok(false);
}
if key.code == KeyCode::Char('r')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
open_rollback(app);
return Ok(false);
}
if (key.code == KeyCode::Char('h')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| (key.code == KeyCode::Char('?') && app.focus != Focus::AiPrompt)
{
open_help(app);
return Ok(false);
}
if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
if app.focus == Focus::Editor {
app.stash_editor();
}
app.focus = app.focus.next();
app.first_launch = false;
app.status = format!("focus → {}", app.focus.label());
return Ok(false);
}
if key.code == KeyCode::BackTab
|| (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
{
if app.focus == Focus::Editor {
app.stash_editor();
}
app.focus = app.focus.prev();
app.first_launch = false;
app.status = format!("focus → {}", app.focus.label());
return Ok(false);
}
match app.focus {
Focus::List => dispatch_list_keys(app, key),
Focus::Editor => dispatch_editor_keys(app, key),
Focus::AiPrompt => dispatch_ai_prompt_keys(app, key),
}
Ok(false)
}
fn is_valid_prompt_name(name: &str) -> bool {
let mut chars = name.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn dispatch_list_keys(app: &mut App, key: KeyEvent) {
let n = app.library.prompts.len();
match key.code {
KeyCode::Up => {
if app.cursor > 0 {
app.stash_editor();
app.cursor -= 1;
app.reload_editor();
app.first_launch = false;
}
}
KeyCode::Down => {
if app.cursor + 1 < n {
app.stash_editor();
app.cursor += 1;
app.reload_editor();
app.first_launch = false;
}
}
KeyCode::PageUp => {
app.stash_editor();
app.cursor = app.cursor.saturating_sub(10);
app.reload_editor();
}
KeyCode::PageDown => {
app.stash_editor();
app.cursor = (app.cursor + 10).min(n.saturating_sub(1));
app.reload_editor();
}
KeyCode::Home => {
app.stash_editor();
app.cursor = 0;
app.reload_editor();
}
KeyCode::End => {
app.stash_editor();
app.cursor = n.saturating_sub(1);
app.reload_editor();
}
KeyCode::Enter => {
if app.library.prompts.get(app.cursor).is_some() {
app.focus = Focus::Editor;
app.first_launch = false;
app.status = "loaded into editor".into();
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
app.modal = Modal::AddPrompt {
buffer: String::new(),
error: None,
};
}
KeyCode::Char('d') | KeyCode::Char('D') => {
if let Some(prompt) = app.library.prompts.get(app.cursor) {
let name = prompt.name.clone();
if app.removed.contains(&name) {
app.removed.remove(&name);
app.status = format!("revoked deletion of `{name}`");
} else {
app.modal = Modal::DeletePromptConfirm { name };
}
}
}
KeyCode::Char('l') | KeyCode::Char('L') => {
if let Some(prompt) = app.library.prompts.get_mut(app.cursor) {
let next = match prompt.language.as_deref() {
None => Some("en".to_string()),
Some("en") => Some("ru".to_string()),
Some("ru") => Some("es".to_string()),
Some("es") => Some("de".to_string()),
Some("de") => Some("fr".to_string()),
Some("fr") => None,
_ => None,
};
prompt.language = next.clone();
let name = prompt.name.clone();
app.dirty.insert(name.clone());
app.status = match next {
Some(code) => format!("`{name}` language → {code}"),
None => format!("`{name}` language → untagged"),
};
}
}
_ => {}
}
}
fn dispatch_editor_keys(app: &mut App, key: KeyEvent) {
let input: tui_textarea::Input = key.into();
let _ = app.editor.input(input);
app.first_launch = false;
}
fn open_rollback(app: &mut App) {
match backup::list(&app.project_root) {
Ok(entries) if !entries.is_empty() => {
app.modal = Modal::Rollback { entries, cursor: 0 };
}
Ok(_) => {
app.status = format!(
"rollback: no backups yet · save once to populate {}/{}/",
app.project_root.display(),
backup::BACKUP_DIR,
);
}
Err(e) => {
app.status = format!("rollback list failed: {e:#}");
}
}
}
fn stage_rollback(app: &mut App, entry: &BackupEntry) -> Result<usize> {
let raw = backup::read(entry)?;
let restored: PromptLibrary = serde_hjson::from_str(&raw)
.with_context(|| format!("parse {}", entry.path.display()))?;
let mut current_by_name: std::collections::HashMap<String, &Prompt> =
std::collections::HashMap::new();
for p in &app.library.prompts {
current_by_name.insert(p.name.clone(), p);
}
let restored_names: std::collections::HashSet<String> =
restored.prompts.iter().map(|p| p.name.clone()).collect();
let mut staged: usize = 0;
let mut new_library = PromptLibrary::default();
for restored_prompt in &restored.prompts {
let name = restored_prompt.name.clone();
let was_present = current_by_name.contains_key(&name);
if !was_present {
app.added.insert(name.clone());
app.dirty.insert(name.clone());
staged += 1;
} else if let Some(live) = current_by_name.get(&name).copied() {
if live.template != restored_prompt.template
|| live.description != restored_prompt.description
{
app.dirty.insert(name.clone());
staged += 1;
}
app.removed.remove(&name);
}
new_library.prompts.push(restored_prompt.clone());
}
for current_prompt in &app.library.prompts {
if !restored_names.contains(¤t_prompt.name) {
app.removed.insert(current_prompt.name.clone());
new_library.prompts.push((*current_prompt).clone());
staged += 1;
}
}
new_library
.prompts
.sort_by(|a, b| a.name.cmp(&b.name));
app.library = new_library;
if app.cursor >= app.library.prompts.len() && app.cursor > 0 {
app.cursor = app.library.prompts.len().saturating_sub(1);
}
app.reload_editor();
Ok(staged)
}
fn insert_ai_response_into_editor(app: &mut App) {
let response_text = match app.last_send.as_ref() {
Some(send) if !send.response.trim().is_empty() => send.response.clone(),
Some(_) => {
app.status =
"AI response is empty — wait for it to finish, then Ctrl+G".into();
return;
}
None => {
app.status =
"no AI response yet — Tab to the AI prompt input and press Enter first".into();
return;
}
};
if app.inference.is_some() {
app.status =
"AI response still streaming — wait for it to finish".into();
return;
}
app.editor.insert_str(&response_text);
app.stash_editor();
app.status = format!(
"inserted {} chars of AI response into editor",
response_text.chars().count(),
);
}
fn dispatch_ai_prompt_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
send_ai_prompt(app);
}
KeyCode::Up => {
if app.ai_history.is_empty() {
return;
}
let new_cursor = match app.ai_history_cursor {
None => Some(app.ai_history.len() - 1),
Some(0) => Some(0),
Some(i) => Some(i - 1),
};
app.ai_history_cursor = new_cursor;
if let Some(i) = new_cursor {
let text = app.ai_history[i].clone();
let len = text.chars().count();
app.ai_input.set_with_cursor(text, len);
}
}
KeyCode::Down => {
let Some(i) = app.ai_history_cursor else {
return;
};
if i + 1 < app.ai_history.len() {
app.ai_history_cursor = Some(i + 1);
let text = app.ai_history[i + 1].clone();
let len = text.chars().count();
app.ai_input.set_with_cursor(text, len);
} else {
app.ai_history_cursor = None;
app.ai_input.clear();
}
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.ai_input.clear();
app.ai_history_cursor = None;
}
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.ai_input.clear();
app.ai_history.clear();
app.ai_history_cursor = None;
app.status = "ai prompt: input + history cleared".into();
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.ai_input.move_home();
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.ai_input.move_end();
}
KeyCode::Home => {
app.ai_input.move_home();
}
KeyCode::End => {
app.ai_input.move_end();
}
KeyCode::Left => {
app.ai_input.move_left();
}
KeyCode::Right => {
app.ai_input.move_right();
}
KeyCode::Backspace => {
app.ai_input.backspace();
app.ai_history_cursor = None;
}
KeyCode::Delete => {
app.ai_input.delete();
app.ai_history_cursor = None;
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
app.ai_input.insert_char(c);
app.ai_history_cursor = None;
}
_ => {}
}
}
fn send_ai_prompt(app: &mut App) {
let Some(rt) = app.ai.clone() else {
app.status =
"LLM not configured — set llm.default in inkhaven.hjson + provide its API key".into();
return;
};
app.stash_editor();
let editor_body = app
.library
.prompts
.get(app.cursor)
.map(|p| p.template.clone())
.unwrap_or_default();
if editor_body.trim().is_empty() {
app.status =
"prompt body is empty — focus the editor pane and write a prompt first".into();
return;
}
let prompt_name = app
.library
.prompts
.get(app.cursor)
.map(|p| p.name.clone());
let raw_input = app.ai_input.as_str().to_string();
let instruction = raw_input.trim();
let analysis_request = if instruction.is_empty() {
DEFAULT_ANALYSIS_REQUEST.to_string()
} else {
instruction.to_string()
};
let rendered = build_analysis_request(&editor_body, &analysis_request);
if !instruction.is_empty()
&& app.ai_history.last().map(String::as_str) != Some(instruction)
{
app.ai_history.push(instruction.to_string());
}
app.ai_history_cursor = None;
app.ai_input.clear();
let rx = spawn_chat_stream(
rt.client.client.clone(),
rt.model.clone(),
Some(ANALYSIS_SYSTEM_PROMPT.to_string()),
Vec::new(),
rendered,
);
app.last_send = Some(Send {
prompt_name,
template_under_review: editor_body,
analysis_request,
response: String::new(),
started_at: Instant::now(),
duration: None,
failed: false,
});
app.inference = Some(Inference {
rx,
started_at: Instant::now(),
});
app.status =
format!("analysing prompt via {} ({})…", rt.provider, rt.model);
}
fn pump_inference(app: &mut App) {
let done = {
let Some(inf) = app.inference.as_mut() else {
return;
};
let mut finished = false;
loop {
match inf.rx.try_recv() {
Ok(StreamMsg::Token(chunk)) => {
if let Some(send) = app.last_send.as_mut() {
send.response.push_str(&chunk);
}
}
Ok(StreamMsg::Done) => {
if let Some(send) = app.last_send.as_mut() {
send.duration = Some(send.started_at.elapsed());
}
finished = true;
break;
}
Ok(StreamMsg::Error(e)) => {
if let Some(send) = app.last_send.as_mut() {
send.failed = true;
if !send.response.is_empty() {
send.response.push_str("\n\n");
}
send.response.push_str(&format!("⚠ ERROR: {e}"));
send.duration = Some(send.started_at.elapsed());
}
finished = true;
break;
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
if let Some(send) = app.last_send.as_mut() {
if send.duration.is_none() {
send.duration = Some(send.started_at.elapsed());
}
}
finished = true;
break;
}
}
}
finished
};
if done {
app.inference = None;
if let Some(send) = app.last_send.as_ref() {
if send.failed {
app.status = "AI response: FAILED".into();
} else {
let secs = send
.duration
.map(|d| d.as_secs_f32())
.unwrap_or(0.0);
app.status = format!("AI response ready · {secs:.1}s");
}
}
}
}
fn perform_save(app: &mut App) -> Result<String> {
app.stash_editor();
let mut library = app.library.clone();
if !app.removed.is_empty() {
library
.prompts
.retain(|p| !app.removed.contains(&p.name));
}
let body = serde_hjson::to_string(&library)
.context("serialise library to HJSON")?;
let mut tmp_path = app.prompts_path.clone();
let mut tmp_name = app
.prompts_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
tmp_name.push_str(".tmp");
tmp_path.set_file_name(&tmp_name);
fs::write(&tmp_path, &body)
.with_context(|| format!("write {}", tmp_path.display()))?;
fs::rename(&tmp_path, &app.prompts_path).with_context(|| {
format!(
"rename {} → {}",
tmp_path.display(),
app.prompts_path.display()
)
})?;
let backup_dir = app.project_root.join(".prompts-backups");
fs::create_dir_all(&backup_dir)
.with_context(|| format!("create {}", backup_dir.display()))?;
let ts = Local::now().format("%Y%m%d_%H%M%S").to_string();
let backup_path = backup_dir.join(format!("prompts_{ts}.hjson"));
fs::write(&backup_path, &body)
.with_context(|| format!("write {}", backup_path.display()))?;
let summary = app.save_summary();
app.library
.prompts
.retain(|p| !app.removed.contains(&p.name));
app.removed.clear();
app.added.clear();
app.dirty.clear();
app.loaded_from_defaults = false;
app.baseline = build_baseline(&app.library);
if app.cursor >= app.library.prompts.len() && app.cursor > 0 {
app.cursor = app.library.prompts.len().saturating_sub(1);
}
app.reload_editor();
app.status = format!(
"saved · {} prompts · backup {}",
app.library.prompts.len(),
backup_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
);
let mut msg = String::new();
msg.push_str(&format!(
"Saved {} entries to {}.\n",
app.library.prompts.len(),
app.prompts_path.display(),
));
msg.push_str(&format!("Backup: {}\n\n", backup_path.display()));
if !summary.added.is_empty() {
msg.push_str(&format!("Added: {}\n", summary.added.join(", ")));
}
if !summary.modified.is_empty() {
msg.push_str(&format!("Modified: {}\n", summary.modified.join(", ")));
}
if !summary.removed.is_empty() {
msg.push_str(&format!("Removed: {}\n", summary.removed.join(", ")));
}
Ok(msg)
}
fn open_help(app: &mut App) {
let body = match app.focus {
Focus::List => list_help_body(),
Focus::Editor => editor_help_body(),
Focus::AiPrompt => ai_prompt_help_body(),
};
app.modal = Modal::Help { body };
app.first_launch = false;
}
fn list_help_body() -> String {
[
" Prompts list — chord summary",
"",
" ↑↓ navigate entries",
" PgUp / PgDn jump 10 entries",
" Home / End first / last entry",
" a add new prompt (name prompt)",
" d delete focused prompt (confirm)",
" second `d` on a deleted entry revokes",
" l cycle language tag (—/en/ru/es/de/fr) (1.2.12+)",
" Tab / Shift+Tab cycle pane focus",
" Ctrl+S save library (confirm modal)",
" Ctrl+R rollback picker (list .prompts-backups/)",
" Esc / Ctrl+Q quit (confirm if unsaved)",
"",
" Status chips:",
" ✱ unsaved edit",
" ✚ newly added (staged)",
" ✗ staged for deletion (strike-through)",
"",
" Phase 3 adds: live LLM evaluation in the AI pane.",
]
.join("\n")
}
fn editor_help_body() -> String {
[
" Prompt editor — chord summary",
"",
" Arrows / Home / End / PgUp / PgDn",
" movement",
" Shift+arrows extend selection",
" Backspace / Del delete character",
" Ctrl+A / Ctrl+E start / end of line",
" Ctrl+B / Ctrl+F cursor left / right",
" Ctrl+N / Ctrl+P cursor down / up",
" Ctrl+K kill to end of line",
" Ctrl+W delete previous word",
" Ctrl+U / Ctrl+Y undo / redo",
" Type to insert.",
"",
" AI-response insertion (works from any pane):",
" Ctrl+B G \"Get response\" — insert the latest",
" AI pane response at the editor cursor",
" and jump focus to the editor. Used",
" to be plain Ctrl+G but the terminal",
" eats Ctrl+G as ASCII BEL on most",
" setups, so it moved to the meta",
" prefix. No-op (with status message)",
" when the response is missing or",
" still streaming.",
"",
" App-global chords (intercepted before the editor sees them):",
" Ctrl+S save library (confirm modal)",
" Ctrl+H / ? help (this pane)",
" Ctrl+Q / Esc quit (confirm if unsaved)",
" Tab / Shift+Tab cycle pane focus",
"",
" Template variables — DOCUMENTATION ONLY in this editor.",
" These placeholders are NOT substituted when you send the",
" template to the reviewer LLM; the reviewer sees them as",
" literal text and comments on them as part of the critique.",
"",
" {{selection}} inkhaven substitutes the selected",
" prose at runtime in the main editor",
" {{context}} inkhaven substitutes the hierarchical",
" book/chapter/subchapter context",
]
.join("\n")
}
fn ai_prompt_help_body() -> String {
[
" AI prompt input — chord summary",
"",
" type to edit · Backspace / Delete remove",
" Left / Right / Home / End / Ctrl+A / Ctrl+E",
" cursor movement",
" Up / Down history walk (in-session)",
" Enter SEND for analysis",
" Ctrl+L clear input",
" Ctrl+K clear input + clear history",
"",
" What gets sent — the LLM acts as a prompt",
" reviewer, NOT as an executor of your template.",
"",
" system → a fixed framing that tells the LLM",
" it's reviewing a prompt template,",
" not running one. Placeholders like",
" {{selection}} are explained as",
" runtime substitutions inkhaven",
" handles later.",
" user → fenced template body (your editor",
" pane verbatim) + your typed",
" analysis request. Placeholders are",
" NOT substituted — the reviewer sees",
" them as-is so it can comment on",
" their use.",
"",
" Empty AI prompt input — Enter still works. A",
" sensible default request kicks in:",
" \"Critique this prompt template. Identify",
" strengths and weaknesses, comment on",
" placeholder use, suggest improvements.\"",
"",
" Single-shot per send — each Enter is an",
" independent review; there's no conversation",
" history between sends.",
]
.join("\n")
}
fn render(f: &mut ratatui::Frame, app: &mut App) {
let size = f.area();
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(3), Constraint::Length(1), ])
.split(size);
draw_top_bar(f, v_chunks[0], app);
draw_body(f, v_chunks[1], app);
draw_ai_prompt(f, v_chunks[2], app);
draw_status(f, v_chunks[3], app);
match &app.modal {
Modal::None => {
if app.first_launch {
draw_welcome_overlay(f, size, app);
}
}
Modal::Help { body } => draw_help_modal(f, size, body),
Modal::Saved { message } => draw_saved_overlay(f, size, message),
Modal::SaveConfirm { summary } => {
draw_save_confirm(f, size, &app.prompts_path, summary);
}
Modal::AddPrompt { buffer, error } => {
draw_add_prompt(f, size, buffer, error.as_deref());
}
Modal::DeletePromptConfirm { name } => {
draw_delete_prompt_confirm(f, size, name, app.added.contains(name));
}
Modal::DiscardConfirm { unsaved } => {
draw_discard_confirm(f, size, *unsaved);
}
Modal::Rollback { entries, cursor } => {
draw_rollback_picker(f, size, entries, *cursor);
}
Modal::RollbackDelete { entry } => {
draw_rollback_delete_confirm(f, size, entry);
}
Modal::RollbackPreview { entry, body, scroll } => {
draw_rollback_preview(f, size, entry, body, *scroll);
}
}
}
fn draw_top_bar(f: &mut ratatui::Frame, area: Rect, app: &App) {
let mut spans: Vec<Span<'_>> = Vec::new();
spans.push(Span::styled(
" inkhaven prompts-editor ",
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{}", app.prompts_path.display()),
Style::default().add_modifier(Modifier::BOLD),
));
if app.loaded_from_defaults {
spans.push(Span::raw(" "));
spans.push(Span::styled(
" from embedded defaults ",
Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
));
}
if app.meta_pending {
spans.push(Span::raw(" "));
spans.push(Span::styled(
" META ",
Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
));
}
let unsaved = app.unsaved_count();
if unsaved > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!(" {unsaved} unsaved "),
Style::default()
.bg(Color::Red)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!(" focus: {} ", app.focus.label()),
Style::default()
.bg(Color::Magenta)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_body(f: &mut ratatui::Frame, area: Rect, app: &mut App) {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(22),
Constraint::Percentage(43),
Constraint::Percentage(35),
])
.split(area);
let list_rect = h_chunks[0];
let editor_rect = h_chunks[1];
let ai_rect = h_chunks[2];
draw_list_pane(f, list_rect, app);
draw_editor_pane(f, editor_rect, app);
draw_ai_pane(f, ai_rect, app);
}
fn draw_list_pane(f: &mut ratatui::Frame, area: Rect, app: &mut App) {
let focused = app.focus == Focus::List;
let n = app.library.prompts.len();
let inner_h = area.height.saturating_sub(2) as usize;
if app.cursor < app.list_scroll {
app.list_scroll = app.cursor;
} else if inner_h > 0 && app.cursor >= app.list_scroll + inner_h {
app.list_scroll = app.cursor + 1 - inner_h;
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Prompts ({n}) "))
.border_style(border_style(focused));
let inner = block.inner(area);
f.render_widget(block, area);
if n == 0 {
let body = vec![Line::from(Span::styled(
" (empty — Phase 2 ships `a` to add)",
Style::default().add_modifier(Modifier::DIM),
))];
f.render_widget(Paragraph::new(body), inner);
return;
}
let mut lines: Vec<Line<'_>> = Vec::with_capacity(inner_h);
for (i, prompt) in app
.library
.prompts
.iter()
.enumerate()
.skip(app.list_scroll)
.take(inner_h)
{
let selected = i == app.cursor;
let removed = app.removed.contains(&prompt.name);
let added = app.added.contains(&prompt.name);
let dirty = app.dirty.contains(&prompt.name);
let chip = if removed {
"✗"
} else if added {
"✚"
} else if dirty {
"✱"
} else {
" "
};
let style = if selected {
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else if removed {
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::CROSSED_OUT | Modifier::DIM)
} else if added {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else if dirty {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if selected { "▶" } else { " " };
let lang_chip = match prompt.language.as_deref() {
Some(l) => format!("[{l}]"),
None => "[—]".to_string(),
};
let lang_chip_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::DIM);
lines.push(Line::from(vec![
Span::raw(format!(" {marker} ")),
Span::raw(format!("{chip} ")),
Span::styled(lang_chip, lang_chip_style),
Span::raw(" "),
Span::styled(prompt.name.clone(), style),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_editor_pane(f: &mut ratatui::Frame, area: Rect, app: &mut App) {
let focused = app.focus == Focus::Editor;
let title = match app.current_prompt() {
Some(p) => {
let name = p.name.clone();
let dirty_marker = if app.dirty.contains(&name) {
" · ✱ unsaved"
} else if app.removed.contains(&name) {
" · ✗ DELETING"
} else if app.added.contains(&name) {
" · ✚ NEW"
} else {
""
};
format!(" Editor — `{name}`{dirty_marker} ")
}
None => " Editor ".to_string(),
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style(focused));
let inner = block.inner(area);
f.render_widget(block, area);
if app.library.prompts.is_empty() {
let body = vec![Line::from(Span::styled(
" (no prompts — focus the list and press `a` to add one)",
Style::default().add_modifier(Modifier::DIM),
))];
f.render_widget(Paragraph::new(body), inner);
return;
}
let cursor_style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let line_style = Style::default();
app.editor.set_cursor_style(cursor_style);
app.editor.set_cursor_line_style(line_style);
app.editor.set_block(Block::default());
f.render_widget(&app.editor, inner);
}
fn draw_ai_pane(f: &mut ratatui::Frame, area: Rect, app: &App) {
let title = ai_pane_title(app);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style(false));
let inner = block.inner(area);
f.render_widget(block, area);
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'_>> = Vec::new();
if app.ai.is_none() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ⚠ LLM not configured",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Set `llm.default` in inkhaven.hjson and",
dim,
)));
lines.push(Line::from(Span::styled(
" provide its API-key env var, then relaunch.",
dim,
)));
f.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }),
inner,
);
return;
}
let Some(send) = app.last_send.as_ref() else {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" (no analysis yet)",
dim,
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Tab to the AI prompt input. Press Enter to ask",
dim,
)));
lines.push(Line::from(Span::styled(
" the LLM to critique the focused prompt template.",
dim,
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Empty input → default critique request.",
dim,
)));
lines.push(Line::from(Span::styled(
" Typed input → that text becomes the analysis",
dim,
)));
lines.push(Line::from(Span::styled(
" request (e.g. \"is this clear?\", \"shorten\").",
dim,
)));
f.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }),
inner,
);
return;
};
let header = match send.prompt_name.as_deref() {
Some(n) => format!(" ▸ template under review · `{n}`"),
None => " ▸ template under review".to_string(),
};
lines.push(Line::from(Span::styled(header, bold)));
if send.template_under_review.trim().is_empty() {
lines.push(Line::from(Span::styled(
" (empty)",
dim,
)));
} else {
for body in send.template_under_review.lines() {
lines.push(Line::from(format!(" {body}")));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" ▸ analysis request", bold)));
for body in send.analysis_request.lines() {
lines.push(Line::from(format!(" {body}")));
}
lines.push(Line::from(""));
let header_style = if send.failed {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
bold
};
lines.push(Line::from(Span::styled(" ▸ assistant", header_style)));
if send.response.is_empty() && send.duration.is_none() {
let elapsed = send.started_at.elapsed();
let secs = elapsed.as_secs_f32();
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let idx = (elapsed.as_millis() / 100) as usize % spinner_frames.len();
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
spinner_frames[idx],
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("streaming · {secs:.1}s"),
dim,
),
]));
} else if send.response.is_empty() {
lines.push(Line::from(Span::styled(
" (empty response)",
dim,
)));
} else {
for body in send.response.lines() {
lines.push(Line::from(format!(" {body}")));
}
}
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn ai_pane_title(app: &App) -> String {
let Some(rt) = app.ai.as_ref() else {
return " AI response ".to_string();
};
let model = rt.model.as_str();
let provider = rt.provider.as_str();
match (app.inference.as_ref(), app.last_send.as_ref()) {
(Some(_), _) => format!(" AI · {provider} · {model} · streaming "),
(None, Some(send)) => {
if send.failed {
format!(" AI · {provider} · {model} · FAILED ")
} else if let Some(d) = send.duration {
format!(
" AI · {provider} · {model} · {:.1}s ",
d.as_secs_f32()
)
} else {
format!(" AI · {provider} · {model} ")
}
}
(None, None) => format!(" AI · {provider} · {model} "),
}
}
fn draw_ai_prompt(f: &mut ratatui::Frame, area: Rect, app: &App) {
let focused = app.focus == Focus::AiPrompt;
let title = if app.inference.is_some() {
" Test prompt · sending… ".to_string()
} else if app.ai.is_none() {
" Test prompt · (LLM disabled) ".to_string()
} else {
match &app.ai_history_cursor {
Some(i) => format!(
" Test prompt · history {}/{} ",
i + 1,
app.ai_history.len(),
),
None => " Test prompt ".to_string(),
}
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style(focused));
let inner = block.inner(area);
f.render_widget(block, area);
let dim = Style::default().add_modifier(Modifier::DIM);
let rendered = app.ai_input.render_with_cursor(if focused { '│' } else { ' ' });
let hint = if app.ai.is_none() {
" (LLM not configured — see AI pane)"
} else if app.inference.is_some() {
" (Esc cancels by ending the session; Enter ignored while streaming)"
} else if focused {
" Enter sends · Up/Down history · Ctrl+L clear · Ctrl+K clear+history"
} else {
" Tab to focus · type a test input, Enter sends"
};
let lines = vec![
Line::from(format!(" › {rendered}")),
Line::from(Span::styled(hint, dim)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_status(f: &mut ratatui::Frame, area: Rect, app: &App) {
let dim = Style::default().add_modifier(Modifier::DIM);
let hints = match app.focus {
Focus::List => {
" ↑↓ · a add · d delete · Ctrl+S save · Ctrl+R rollback · Tab next · ? help"
}
Focus::Editor => {
" type · Ctrl+B G insert AI response · Ctrl+S save · Tab next · Ctrl+H help"
}
Focus::AiPrompt => {
" type · Enter send · Up/Down history · Ctrl+L clear · Tab next · Ctrl+H help"
}
};
let pos = format!(" {}/{} ", app.cursor + 1, app.library.prompts.len().max(1));
let spans = vec![
Span::styled(pos, dim),
Span::raw(" "),
Span::raw(app.status.clone()),
Span::raw(" "),
Span::styled(hints, dim),
];
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_help_modal(f: &mut ratatui::Frame, host: Rect, body: &str) {
let w = host.width.saturating_sub(8).min(96);
let h = host.height.saturating_sub(4).min(28);
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Help ")
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let mut lines: Vec<Line<'_>> = Vec::new();
let bold = Style::default().add_modifier(Modifier::BOLD);
let cyan_bold =
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
for raw in body.lines() {
let trimmed = raw.trim_start();
if raw.starts_with(' ')
&& !raw.starts_with(" ")
&& trimmed
.chars()
.next()
.map(|c| c.is_ascii_uppercase())
.unwrap_or(false)
{
lines.push(Line::from(Span::styled(raw.to_string(), cyan_bold)));
} else {
lines.push(Line::from(Span::raw(raw.to_string())));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" any key closes ",
Style::default().add_modifier(Modifier::DIM),
)));
let _ = bold;
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_save_confirm(
f: &mut ratatui::Frame,
host: Rect,
path: &Path,
summary: &SaveSummary,
) {
let entry_count = summary.total();
let max_rows = (entry_count.min(20) as u16).max(1);
let w = host.width.saturating_sub(8).min(96);
let h = (max_rows + 8).min(host.height.saturating_sub(4));
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Save? ")
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().add_modifier(Modifier::DIM);
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Save "),
Span::styled(format!("{entry_count}"), bold),
Span::raw(" pending change"),
Span::raw(if entry_count == 1 { "" } else { "s" }),
Span::raw(" to "),
Span::styled(format!("{}", path.display()), bold),
Span::raw("?"),
]),
Line::from(""),
];
if !summary.added.is_empty() {
lines.push(Line::from(Span::styled(" ✚ added:", bold)));
for name in &summary.added {
lines.push(Line::from(format!(" {name}")));
}
}
if !summary.modified.is_empty() {
lines.push(Line::from(Span::styled(" ✱ modified:", bold)));
for name in &summary.modified {
lines.push(Line::from(format!(" {name}")));
}
}
if !summary.removed.is_empty() {
lines.push(Line::from(Span::styled(" ✗ removed:", bold)));
for name in &summary.removed {
lines.push(Line::from(format!(" {name}")));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" A timestamped copy will land in <project>/.prompts-backups/",
dim,
)));
lines.push(Line::from(Span::styled(
" y / Enter confirm · n / Esc cancel",
dim,
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_saved_overlay(f: &mut ratatui::Frame, host: Rect, message: &str) {
let w = host.width.saturating_sub(8).min(96);
let h: u16 = 12;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Saved ")
.border_style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let mut lines: Vec<Line<'_>> = vec![Line::from("")];
for line in message.lines() {
lines.push(Line::from(Span::raw(format!(" {line}"))));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" any key dismisses",
Style::default().add_modifier(Modifier::DIM),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_add_prompt(
f: &mut ratatui::Frame,
host: Rect,
buffer: &str,
error: Option<&str>,
) {
let w = host.width.saturating_sub(8).min(72);
let h: u16 = 10;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Add prompt ")
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(" Name:", bold)),
Line::from(format!(" {buffer}│")),
Line::from(""),
];
if let Some(err) = error {
lines.push(Line::from(Span::styled(
format!(" ⚠ {err}"),
Style::default().fg(Color::Red),
)));
} else {
lines.push(Line::from(Span::styled(
" Identifier: letter or `_` to start, then letters/digits/`_`/`-`",
dim,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Enter commits (stages — Ctrl+S to write) · Esc cancels",
dim,
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_delete_prompt_confirm(
f: &mut ratatui::Frame,
host: Rect,
name: &str,
is_newly_added: bool,
) {
let w = host.width.saturating_sub(8).min(72);
let h: u16 = 9;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Delete prompt? ")
.border_style(
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let detail = if is_newly_added {
" Drops the unsaved addition entirely."
} else {
" Stages deletion (struck-through until Ctrl+S commits)."
};
let lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" Delete "),
Span::styled(name.to_string(), bold),
Span::raw(" ?"),
]),
Line::from(""),
Line::from(Span::styled(detail, dim)),
Line::from(""),
Line::from(Span::styled(
" y / Enter confirm · n / Esc cancel",
dim,
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_discard_confirm(f: &mut ratatui::Frame, host: Rect, unsaved: usize) {
let w = host.width.saturating_sub(8).min(72);
let h: u16 = 8;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Discard unsaved changes? ")
.border_style(
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{unsaved} pending change{}", if unsaved == 1 { "" } else { "s" }),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" will be lost."),
]),
Line::from(""),
Line::from(Span::styled(
" y / Enter discard + quit · n / Esc keep editing",
Style::default().add_modifier(Modifier::DIM),
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_rollback_picker(
f: &mut ratatui::Frame,
host: Rect,
entries: &[BackupEntry],
cursor: usize,
) {
let w = host.width.saturating_sub(8).min(96);
let h = (entries.len() as u16 + 6).min(host.height.saturating_sub(4));
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Prompts rollback — {} backups ", entries.len()))
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let now = chrono::Local::now();
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'_>> = Vec::with_capacity(entries.len() + 2);
for (i, entry) in entries.iter().enumerate() {
let selected = i == cursor;
let style = if selected {
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::default()
};
let marker = if selected { "▶" } else { " " };
let rel = backup::relative_time(entry, now);
let abs = entry
.timestamp
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| entry.filename.clone());
let size = if entry.size_bytes < 1024 {
format!("{} B", entry.size_bytes)
} else {
format!("{:.1} KB", entry.size_bytes as f64 / 1024.0)
};
lines.push(Line::from(vec![
Span::styled(format!(" {marker} "), bold),
Span::styled(abs, style),
Span::styled(format!(" ({rel}, {size})"), dim),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Enter restore (stages — Ctrl+S to commit) · v preview · d delete · Esc back",
dim,
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_rollback_delete_confirm(
f: &mut ratatui::Frame,
host: Rect,
entry: &BackupEntry,
) {
let w = host.width.saturating_sub(8).min(72);
let h: u16 = 8;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Delete backup? ")
.border_style(
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(entry.filename.clone(), bold),
]),
Line::from(""),
Line::from(Span::styled(
" This cannot be undone.",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" y / Enter delete · n / Esc cancel",
dim,
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_rollback_preview(
f: &mut ratatui::Frame,
host: Rect,
entry: &BackupEntry,
body: &str,
scroll: usize,
) {
let w = host.width.saturating_sub(4).min(120);
let h = host.height.saturating_sub(2).min(40);
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Preview — {} ", entry.filename))
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let visible = inner.height.saturating_sub(2) as usize;
let total = body.lines().count();
let mut lines: Vec<Line<'_>> = body
.lines()
.skip(scroll)
.take(visible)
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
if lines.is_empty() {
lines.push(Line::from(Span::styled(
" (empty file)",
Style::default().add_modifier(Modifier::DIM),
)));
}
let last_line = (scroll + visible).min(total);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
" lines {}-{} of {} · ↑↓ PgUp PgDn Home End scroll · Esc back",
scroll + 1,
last_line.max(scroll + 1),
total,
),
Style::default().add_modifier(Modifier::DIM),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_welcome_overlay(f: &mut ratatui::Frame, host: Rect, app: &App) {
let w = host.width.saturating_sub(8).min(72);
let h: u16 = 12;
let x = host.x + host.width.saturating_sub(w) / 2;
let y = host.y + host.height.saturating_sub(h) / 2;
let rect = Rect { x, y, width: w, height: h };
f.render_widget(ratatui::widgets::Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(" Welcome — prompts editor · Phase 1 ")
.border_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(rect);
f.render_widget(block, rect);
let dim = Style::default().add_modifier(Modifier::DIM);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'_>> = vec![
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{} prompts loaded", app.library.prompts.len()),
bold,
),
Span::raw(" from "),
Span::styled(
if app.loaded_from_defaults {
"(embedded defaults)".to_string()
} else {
app.prompts_path.display().to_string()
},
bold,
),
]),
Line::from(""),
Line::from(Span::raw(" Phase 1 is a read-only walk-through:")),
Line::from(Span::raw(" · ↑↓ to navigate the prompts list")),
Line::from(Span::raw(" · Tab to cycle between the three editable panes")),
Line::from(Span::raw(" · ? or Ctrl+H for focus-aware help")),
Line::from(""),
Line::from(Span::styled(
" any key dismisses this banner",
dim,
)),
];
while (lines.len() as u16) < h.saturating_sub(2) {
lines.push(Line::from(""));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn border_style(focused: bool) -> Style {
if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::DIM)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::prompts::Prompt;
#[test]
fn focus_next_cycles_three_panes() {
assert_eq!(Focus::List.next(), Focus::Editor);
assert_eq!(Focus::Editor.next(), Focus::AiPrompt);
assert_eq!(Focus::AiPrompt.next(), Focus::List);
}
#[test]
fn focus_prev_reverses() {
assert_eq!(Focus::List.prev(), Focus::AiPrompt);
assert_eq!(Focus::AiPrompt.prev(), Focus::Editor);
assert_eq!(Focus::Editor.prev(), Focus::List);
}
#[test]
fn embedded_defaults_parse_cleanly() {
let lib: PromptLibrary = serde_hjson::from_str(DEFAULT_PROMPTS)
.expect("embedded DEFAULT_PROMPTS must be valid HJSON");
assert!(
!lib.prompts.is_empty(),
"embedded defaults should ship at least one prompt"
);
let names: Vec<&str> =
lib.prompts.iter().map(|p| p.name.as_str()).collect();
assert!(
names.iter().any(|n| n.contains("tighten") || n.contains("darker")),
"expected at least one of the bundled default prompts, got {names:?}"
);
}
#[test]
fn help_bodies_have_expected_headings() {
assert!(list_help_body().starts_with(" Prompts list"));
assert!(editor_help_body().starts_with(" Prompt editor"));
assert!(ai_prompt_help_body().starts_with(" AI prompt input"));
}
#[test]
fn current_prompt_handles_empty_library() {
let app = App {
project_root: PathBuf::from("/tmp"),
prompts_path: PathBuf::from("/tmp/prompts.hjson"),
library: PromptLibrary::default(),
cursor: 0,
list_scroll: 0,
editor: TextArea::default(),
dirty: HashSet::new(),
added: HashSet::new(),
removed: HashSet::new(),
baseline: std::collections::HashMap::new(),
first_launch: false,
loaded_from_defaults: false,
focus: Focus::List,
modal: Modal::None,
status: String::new(),
ai: None,
ai_input: TextInput::new(),
ai_history: Vec::new(),
ai_history_cursor: None,
last_send: None,
inference: None,
meta_pending: false,
};
assert!(app.current_prompt().is_none());
}
#[test]
fn analysis_request_includes_template_verbatim_with_placeholders() {
let body = "Tighten:\n\n{{selection}}\n\nContext: {{context}}";
let out = build_analysis_request(body, "is this clear?");
assert!(out.contains("{{selection}}"));
assert!(out.contains("{{context}}"));
assert!(out.contains("Tighten:"));
assert!(out.contains("is this clear?"));
}
#[test]
fn analysis_request_uses_fenced_markers() {
let out = build_analysis_request("body text", "instruction");
assert!(out.contains("--- PROMPT TEMPLATE UNDER REVIEW ---"));
assert!(out.contains("--- END TEMPLATE ---"));
assert!(out.contains("Analysis request:"));
}
#[test]
fn analysis_request_falls_back_to_default_when_input_empty() {
let out = build_analysis_request("body", "");
assert!(out.contains(DEFAULT_ANALYSIS_REQUEST));
}
#[test]
fn analysis_request_falls_back_to_default_on_whitespace_only_input() {
let out = build_analysis_request("body", " \t \n ");
assert!(out.contains(DEFAULT_ANALYSIS_REQUEST));
}
#[test]
fn analysis_request_trims_user_instruction() {
let out = build_analysis_request("body", " rewrite to be concise ");
assert!(out.contains("Analysis request:\nrewrite to be concise"));
assert!(!out.contains("Analysis request:\n rewrite"));
}
#[test]
fn system_prompt_frames_reviewer_role() {
assert!(ANALYSIS_SYSTEM_PROMPT.contains("Do NOT try to execute"));
assert!(ANALYSIS_SYSTEM_PROMPT.contains("reviewer"));
}
#[test]
fn is_valid_prompt_name_accepts_typical_idents() {
assert!(is_valid_prompt_name("critique-edit"));
assert!(is_valid_prompt_name("show_dont_tell"));
assert!(is_valid_prompt_name("_internal"));
assert!(is_valid_prompt_name("Foo123"));
}
#[test]
fn is_valid_prompt_name_rejects_leading_digit_and_empty() {
assert!(!is_valid_prompt_name(""));
assert!(!is_valid_prompt_name("1foo"));
assert!(!is_valid_prompt_name("-leading-dash"));
assert!(!is_valid_prompt_name("has space"));
assert!(!is_valid_prompt_name("dotted.name"));
}
#[test]
fn save_summary_partitions_added_modified_removed() {
let mut lib = PromptLibrary::default();
lib.prompts.push(Prompt {
name: "alpha".into(),
description: "".into(),
template: "a".into(),
language: None,
});
lib.prompts.push(Prompt {
name: "beta".into(),
description: "".into(),
language: None,
template: "b".into(),
});
let mut app = App {
project_root: PathBuf::from("/tmp"),
prompts_path: PathBuf::from("/tmp/prompts.hjson"),
library: lib,
cursor: 0,
list_scroll: 0,
editor: TextArea::default(),
dirty: HashSet::new(),
added: HashSet::new(),
removed: HashSet::new(),
baseline: std::collections::HashMap::new(),
first_launch: false,
loaded_from_defaults: false,
focus: Focus::List,
modal: Modal::None,
status: String::new(),
ai: None,
ai_input: TextInput::new(),
ai_history: Vec::new(),
ai_history_cursor: None,
last_send: None,
inference: None,
meta_pending: false,
};
app.dirty.insert("alpha".into());
app.added.insert("gamma".into());
app.dirty.insert("gamma".into()); app.removed.insert("beta".into());
let s = app.save_summary();
assert_eq!(s.added, vec!["gamma".to_string()]);
assert_eq!(s.modified, vec!["alpha".to_string()]);
assert_eq!(s.removed, vec!["beta".to_string()]);
assert!(!s.modified.contains(&"gamma".to_string()));
}
#[test]
fn has_unsaved_covers_all_three_buckets_and_defaults_load() {
let mut app = App {
project_root: PathBuf::from("/tmp"),
prompts_path: PathBuf::from("/tmp/prompts.hjson"),
library: PromptLibrary::default(),
cursor: 0,
list_scroll: 0,
editor: TextArea::default(),
dirty: HashSet::new(),
added: HashSet::new(),
removed: HashSet::new(),
baseline: std::collections::HashMap::new(),
first_launch: false,
loaded_from_defaults: false,
focus: Focus::List,
modal: Modal::None,
status: String::new(),
ai: None,
ai_input: TextInput::new(),
ai_history: Vec::new(),
ai_history_cursor: None,
last_send: None,
inference: None,
meta_pending: false,
};
assert!(!app.has_unsaved());
app.dirty.insert("x".into());
assert!(app.has_unsaved());
app.dirty.clear();
app.added.insert("y".into());
assert!(app.has_unsaved());
app.added.clear();
app.removed.insert("z".into());
assert!(app.has_unsaved());
app.removed.clear();
app.loaded_from_defaults = true;
assert!(app.has_unsaved());
}
#[test]
fn current_prompt_returns_indexed_entry() {
let mut lib = PromptLibrary::default();
lib.prompts.push(Prompt {
name: "alpha".into(),
description: "first".into(),
template: "alpha body".into(),
language: None,
});
lib.prompts.push(Prompt {
name: "beta".into(),
description: "second".into(),
template: "beta body".into(),
language: None,
});
let app = App {
project_root: PathBuf::from("/tmp"),
prompts_path: PathBuf::from("/tmp/prompts.hjson"),
library: lib,
cursor: 1,
list_scroll: 0,
editor: TextArea::default(),
dirty: HashSet::new(),
added: HashSet::new(),
removed: HashSet::new(),
baseline: std::collections::HashMap::new(),
first_launch: false,
loaded_from_defaults: false,
focus: Focus::List,
modal: Modal::None,
status: String::new(),
ai: None,
ai_input: TextInput::new(),
ai_history: Vec::new(),
ai_history_cursor: None,
last_send: None,
inference: None,
meta_pending: false,
};
let p = app.current_prompt().expect("cursor points at a prompt");
assert_eq!(p.name, "beta");
}
}