use chrono::NaiveDate;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use tui_textarea::{Input, Key, TextArea};
use uuid::Uuid;
use super::super::inference::{Inference, InferenceStatus};
use super::super::modal::Modal;
use super::super::project_goal::{self, ProjectGoalData};
use super::App;
const CONTINUATION_BEGIN: &str = "<<<DRAFT>>>";
const REWRITE_BEGIN: &str = "<<<REWRITE>>>";
const COMMON_END: &str = "<<<END>>>";
impl App {
pub(super) fn start_continuation_draft(&mut self) {
let Some(doc) = self.opened.as_ref() else {
self.status = "continuation: no paragraph open".into();
return;
};
let body = doc.textarea.lines().join("\n");
let title = doc.title.clone();
let (cursor_row, cursor_col) = doc.textarea.cursor();
let marked_source = mark_cursor(&doc.textarea.lines(), cursor_row, cursor_col);
let anchors = self.collect_continuation_anchors(doc.id);
let envelope = compose_continuation_prompt(
&title,
&marked_source,
&anchors,
&self.cfg.language,
);
let _ = body;
let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
Ok(p) => p,
Err(e) => {
self.status = format!("continuation: {e}");
return;
}
};
let model = model.to_string();
let provider = self.ai.default_provider.clone();
let rx = super::super::super::ai::stream::spawn_chat_stream(
self.ai.client.clone(),
model.clone(),
None,
Vec::new(),
envelope,
);
self.inference = Some(Inference {
provider: provider.clone(),
model,
response: String::new(),
status: InferenceStatus::Streaming,
rx,
started_at: std::time::Instant::now(),
});
self.pending_chat_user_msg = None;
self.pending_continuation_draft = true;
self.change_focus(super::super::focus::Focus::Ai);
self.status =
format!("continuation: streaming from {provider}…");
}
fn collect_continuation_anchors(
&self,
current_id: Uuid,
) -> Vec<(String, String)> {
let n = self.cfg.editor.continuation_anchor_count.max(1);
use crate::store::node::NodeKind;
let mut paragraphs: Vec<(Uuid, String, String)> = Vec::new();
for node in self.hierarchy.iter() {
if node.kind != NodeKind::Paragraph {
continue;
}
let mut cur = Some(node.id);
let mut is_system = false;
while let Some(id) = cur {
if let Some(n) = self.hierarchy.get(id) {
if n.system_tag.is_some() {
is_system = true;
break;
}
cur = n.parent_id;
} else {
break;
}
}
if is_system {
continue;
}
let body = match self.store.get_content(node.id) {
Ok(Some(b)) => b,
_ => continue,
};
let body_str =
std::str::from_utf8(&body).unwrap_or("").to_string();
paragraphs.push((node.id, node.title.clone(), body_str));
}
let Some(pos) = paragraphs.iter().position(|(id, _, _)| *id == current_id)
else {
return Vec::new();
};
let start = pos.saturating_sub(n);
paragraphs[start..pos]
.iter()
.map(|(_, t, b)| (t.clone(), b.clone()))
.collect()
}
pub(super) fn start_insert_footnote(&mut self) {
let Some(doc) = self.opened.as_ref() else {
self.status = "footnote: no paragraph open".into();
return;
};
if doc.read_only {
self.status = "footnote: paragraph is read-only".into();
return;
}
let mut textarea = TextArea::default();
textarea.set_cursor_line_style(
Style::default().add_modifier(Modifier::REVERSED),
);
self.modal = Modal::FootnoteEditor {
textarea,
paragraph_id: doc.id,
};
self.status = "footnote · Ctrl+S commit · Esc cancel".into();
}
pub(super) fn footnote_editor_handle_key(
&mut self,
key: KeyEvent,
) -> bool {
let action = {
let Modal::FootnoteEditor { textarea, .. } = &mut self.modal else {
return false;
};
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
if textarea.lines().iter().all(|l| l.is_empty()) {
FootnoteAction::Cancel
} else {
FootnoteAction::None
}
}
(KeyCode::Char('s'), m) if m.contains(KeyModifiers::CONTROL) => {
FootnoteAction::Commit
}
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
FootnoteAction::Cancel
}
_ => {
let input = Input::from(key);
if input.key != Key::Null {
textarea.input(input);
}
FootnoteAction::None
}
}
};
match action {
FootnoteAction::Cancel => {
self.modal = Modal::None;
self.status = "footnote cancelled".into();
true
}
FootnoteAction::Commit => {
self.commit_footnote();
true
}
FootnoteAction::None => true,
}
}
fn commit_footnote(&mut self) {
let (paragraph_id, body) = match std::mem::replace(
&mut self.modal,
Modal::None,
) {
Modal::FootnoteEditor { textarea, paragraph_id } => {
let body = textarea.lines().join("\n");
(paragraph_id, body)
}
other => {
self.modal = other;
return;
}
};
if body.trim().is_empty() {
self.status = "footnote: empty body — nothing inserted".into();
return;
}
let style = self.cfg.editor.footnote_style.to_lowercase();
let style = style.trim();
let Some(doc) = self.opened.as_mut() else {
self.status = "footnote: paragraph closed before commit".into();
return;
};
if doc.id != paragraph_id {
self.status = "footnote: paragraph changed before commit".into();
return;
}
match style {
"markdown" => {
let id: String =
Uuid::new_v4().to_string().chars().take(6).collect();
let marker = format!("[^{id}]");
doc.textarea.insert_str(&marker);
use tui_textarea::CursorMove;
doc.textarea.move_cursor(CursorMove::Bottom);
doc.textarea.move_cursor(CursorMove::End);
doc.textarea.insert_str(&format!("\n\n[^{id}]: {body}"));
}
_ => {
let snippet = format!("#footnote[{body}]");
doc.textarea.insert_str(&snippet);
}
}
doc.dirty = true;
self.status = format!("footnote inserted ({style})");
}
pub(super) fn open_project_goal_modal(&mut self) {
let data = self.compute_project_goal_data();
self.modal = Modal::ProjectGoalModal { data };
self.status = "project goal · Esc closes".into();
}
pub(super) fn project_goal_handle_key(&mut self, key: KeyEvent) -> bool {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
self.modal = Modal::None;
return true;
}
true
}
fn compute_project_goal_data(&self) -> ProjectGoalData {
use crate::store::node::NodeKind;
let goal = self.cfg.project.word_count_goal;
let target = parse_iso_date(&self.cfg.project.target_date);
let counted = self
.cfg
.project
.counted_books
.iter()
.map(|s| s.to_lowercase())
.collect::<Vec<_>>();
let mut per_book: Vec<(String, u64)> = Vec::new();
for book in self.hierarchy.children_of(None) {
if book.kind != NodeKind::Book {
continue;
}
if book.system_tag.is_some() {
continue;
}
if !counted.is_empty()
&& !counted.iter().any(|n| n.eq_ignore_ascii_case(&book.title))
{
continue;
}
let mut total = 0u64;
for id in self.hierarchy.collect_subtree(book.id) {
let Some(n) = self.hierarchy.get(id) else { continue; };
if n.kind == NodeKind::Paragraph {
total += n.word_count;
}
}
per_book.push((book.title.clone(), total));
}
let total_words: u64 = per_book.iter().map(|(_, w)| *w).sum();
let per_book_rows: Vec<(String, u64, u32)> = per_book
.into_iter()
.map(|(title, words)| {
let pct = if total_words == 0 {
0
} else {
((words * 100) / total_words) as u32
};
(title, words, pct)
})
.collect();
let pct = if goal == 0 {
0
} else {
(((total_words.min(u64::MAX as u64) as u128) * 100)
/ (goal as u128)) as u32
};
let pct = pct.min(999);
let remaining = goal.saturating_sub(total_words);
let today = project_goal::today_local();
let days_remaining =
target.map(|t| (t - today).num_days());
let required_per_day = match (days_remaining, remaining) {
(Some(d), r) if d > 0 && r > 0 => Some(((r as i64 + d - 1) / d) as u64),
_ => None,
};
let recent_avg = self.compute_recent_words_per_day();
let projection_date = match (recent_avg, remaining) {
(Some(avg), r) if avg > 0 && r > 0 => {
let days_needed = (r + avg - 1) / avg;
Some(today + chrono::Duration::days(days_needed as i64))
}
_ => None,
};
let verdict = project_goal::verdict_for(
total_words,
goal,
target,
projection_date,
);
ProjectGoalData {
total_words,
goal,
pct,
remaining,
days_remaining,
required_per_day,
recent_avg,
projection_date,
per_book: per_book_rows,
verdict,
}
}
fn compute_recent_words_per_day(&self) -> Option<u64> {
let snap = self.progress_cache.as_ref()?;
if snap.sparkline.is_empty() {
return None;
}
let mut sum: i64 = 0;
let mut active_days = 0u64;
for delta in &snap.sparkline {
if *delta > 0 {
sum += *delta;
active_days += 1;
}
}
if active_days == 0 {
return None;
}
Some((sum as u64) / active_days)
}
pub(super) fn start_style_transfer_picker(&mut self) {
let Some(doc) = self.opened.as_ref() else {
self.status = "style xfer: no paragraph open".into();
return;
};
let target_id = doc.id;
use crate::store::node::NodeKind;
let mut book_id: Option<Uuid> = None;
let mut cur = Some(target_id);
while let Some(id) = cur {
if let Some(node) = self.hierarchy.get(id) {
if node.kind == NodeKind::Book {
book_id = Some(node.id);
break;
}
cur = node.parent_id;
} else {
break;
}
}
let Some(book_id) = book_id else {
self.status = "style xfer: no containing book".into();
return;
};
let mut entries: Vec<(Uuid, String)> = Vec::new();
for id in self.hierarchy.collect_subtree(book_id) {
if id == target_id {
continue;
}
let Some(node) = self.hierarchy.get(id) else { continue; };
if node.kind == NodeKind::Paragraph {
entries.push((node.id, node.title.clone()));
}
}
if entries.is_empty() {
self.status =
"style xfer: no other paragraphs in this book".into();
return;
}
let visible: Vec<usize> = (0..entries.len()).collect();
self.modal = Modal::StyleTransferPicker {
entries,
cursor: 0,
filter: super::super::input::TextInput::new(),
filter_active: false,
visible,
target_paragraph_id: target_id,
};
self.status =
"↑↓ Enter pick voice sample · / filter · Esc cancel".into();
}
pub(super) fn style_transfer_picker_handle_key(
&mut self,
key: KeyEvent,
) -> bool {
let Modal::StyleTransferPicker {
entries,
cursor,
filter,
filter_active,
visible,
..
} = &mut self.modal
else {
return false;
};
if *filter_active {
match key.code {
KeyCode::Esc | KeyCode::Enter => {
*filter_active = false;
return true;
}
KeyCode::Backspace => {
filter.backspace();
let f = filter.as_str().to_lowercase();
let f = f.trim().to_string();
let new_visible: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, (_, t))| f.is_empty() || t.to_lowercase().contains(&f))
.map(|(i, _)| i)
.collect();
*visible = new_visible;
if *cursor >= visible.len() {
*cursor = visible.len().saturating_sub(1);
}
return true;
}
KeyCode::Char(c) => {
filter.insert_char(c);
let f = filter.as_str().to_lowercase();
let f = f.trim().to_string();
let new_visible: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, (_, t))| f.is_empty() || t.to_lowercase().contains(&f))
.map(|(i, _)| i)
.collect();
*visible = new_visible;
if *cursor >= visible.len() {
*cursor = visible.len().saturating_sub(1);
}
return true;
}
_ => return true,
}
}
let visible_len = visible.len();
match key.code {
KeyCode::Up => {
if *cursor > 0 {
*cursor -= 1;
}
true
}
KeyCode::Down => {
if *cursor + 1 < visible_len {
*cursor += 1;
}
true
}
KeyCode::Char('/') => {
*filter_active = true;
true
}
KeyCode::Esc => {
self.modal = Modal::None;
self.status = "style xfer cancelled".into();
true
}
KeyCode::Enter => {
self.commit_style_transfer_picker();
true
}
_ => false,
}
}
fn commit_style_transfer_picker(&mut self) {
let (reference_id, target_id) =
match std::mem::replace(&mut self.modal, Modal::None) {
Modal::StyleTransferPicker {
entries,
cursor,
visible,
target_paragraph_id,
..
} => {
let Some(src_idx) = visible.get(cursor).copied() else { return; };
let Some((id, _)) = entries.get(src_idx).cloned() else { return; };
(id, target_paragraph_id)
}
other => {
self.modal = other;
return;
}
};
let Some(ref_node) = self.hierarchy.get(reference_id).cloned() else {
self.status = "style xfer: reference paragraph vanished".into();
return;
};
let Some(target_node) = self.hierarchy.get(target_id).cloned() else {
self.status = "style xfer: target paragraph vanished".into();
return;
};
let ref_body = self
.store
.get_content(ref_node.id)
.ok()
.flatten()
.and_then(|b| String::from_utf8(b).ok())
.unwrap_or_default();
let target_body = self
.store
.get_content(target_node.id)
.ok()
.flatten()
.and_then(|b| String::from_utf8(b).ok())
.unwrap_or_default();
let envelope = compose_style_transfer_prompt(
&ref_node.title,
&ref_body,
&target_node.title,
&target_body,
);
let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
Ok(p) => p,
Err(e) => {
self.status = format!("style xfer: {e}");
return;
}
};
let model = model.to_string();
let provider = self.ai.default_provider.clone();
let rx = super::super::super::ai::stream::spawn_chat_stream(
self.ai.client.clone(),
model.clone(),
None,
Vec::new(),
envelope,
);
self.inference = Some(Inference {
provider: provider.clone(),
model,
response: String::new(),
status: InferenceStatus::Streaming,
rx,
started_at: std::time::Instant::now(),
});
self.pending_chat_user_msg = None;
self.pending_style_transfer = true;
self.change_focus(super::super::focus::Focus::Ai);
self.status =
format!("style xfer: streaming from {provider}…");
}
}
enum FootnoteAction {
Cancel,
Commit,
None,
}
const CURSOR_MARKER: &str = "[[CURSOR_HERE]]";
fn mark_cursor(lines: &[String], row: usize, col: usize) -> String {
let mut out = String::new();
for (r, line) in lines.iter().enumerate() {
if r > 0 {
out.push('\n');
}
if r == row {
let chars: Vec<char> = line.chars().collect();
let col = col.min(chars.len());
let head: String = chars[..col].iter().collect();
let tail: String = chars[col..].iter().collect();
out.push_str(&head);
out.push_str(CURSOR_MARKER);
out.push_str(&tail);
} else {
out.push_str(line);
}
}
out
}
fn compose_continuation_prompt(
title: &str,
source: &str,
anchors: &[(String, String)],
language: &str,
) -> String {
let lang_hint = if language.trim().is_empty() {
"the project's working language".to_string()
} else {
format!("`{language}`")
};
let anchors_text = if anchors.is_empty() {
"(no prior paragraphs in this book — write fresh)".to_string()
} else {
anchors
.iter()
.map(|(t, b)| format!("── Anchor: {t} ──\n{b}"))
.collect::<Vec<_>>()
.join("\n\n")
};
format!(
"You are continuing a manuscript in the author's voice. Write 2-4 \
sentences continuing the open paragraph from the cursor position.\n\
Use the prior paragraphs (below) as voice anchors — match sentence\n\
length distribution, vocabulary register, narrative distance,\n\
rhythm. Do NOT introduce new plot facts the author hasn't already\n\
set up. Do NOT summarise; CONTINUE.\n\
\n\
Language: {lang_hint}.\n\
\n\
Wrap your draft between `<<<DRAFT>>>` and `<<<END>>>` markers on\n\
lines by themselves. Inside the block put ONLY the continuation\n\
text — no commentary, no explanation, no quotation marks.\n\
\n\
── Voice anchors (chronological, earliest first) ──\n\
{anchors_text}\n\
\n\
── Source paragraph: {title} ──\n\
{source}\n\
── end source ──",
)
}
fn compose_style_transfer_prompt(
ref_title: &str,
ref_body: &str,
target_title: &str,
target_body: &str,
) -> String {
format!(
"You are rewriting one paragraph in the style of a reference\n\
paragraph.\n\
\n\
MATCH:\n\
· sentence-length distribution\n\
· vocabulary register\n\
· narrative distance + voice\n\
· rhythm + mood\n\
\n\
PRESERVE:\n\
· literal meaning\n\
· every named entity (characters, places, artefacts)\n\
· every plot fact\n\
\n\
Wrap the rewritten paragraph between `<<<REWRITE>>>` and\n\
`<<<END>>>` markers on lines by themselves. Inside the block\n\
put ONLY the rewritten paragraph — no commentary, no explanation.\n\
\n\
── Reference paragraph (style source): {ref_title} ──\n\
{ref_body}\n\
\n\
── Target paragraph (to rewrite): {target_title} ──\n\
{target_body}\n\
── end target ──",
)
}
fn parse_iso_date(raw: &str) -> Option<NaiveDate> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").ok()
}
pub(super) fn extract_continuation_text(response: &str) -> Option<String> {
extract_marker_block(response, CONTINUATION_BEGIN)
}
pub(super) fn extract_rewrite_text(response: &str) -> Option<String> {
extract_marker_block(response, REWRITE_BEGIN)
}
fn extract_marker_block(response: &str, begin: &str) -> Option<String> {
let start = response.find(begin)?;
let after = &response[start + begin.len()..];
let end_offset = after.find(COMMON_END)?;
let inner = &after[..end_offset];
let cleaned =
inner.trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
if cleaned.is_empty() {
None
} else {
Some(cleaned.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_continuation_block() {
let r = "preamble\n<<<DRAFT>>>\nShe walked into the rain.\n<<<END>>>\nnotes";
assert_eq!(
extract_continuation_text(r),
Some("She walked into the rain.".to_string())
);
}
#[test]
fn extract_rewrite_block() {
let r = "<<<REWRITE>>>\nIn the marketplace, fire.\n<<<END>>>";
assert_eq!(
extract_rewrite_text(r),
Some("In the marketplace, fire.".to_string())
);
}
#[test]
fn extract_returns_none_on_missing_markers() {
assert!(extract_continuation_text("no markers here").is_none());
assert!(extract_rewrite_text("").is_none());
}
#[test]
fn mark_cursor_inserts_marker_in_correct_position() {
let lines = vec!["hello world".to_string()];
let marked = mark_cursor(&lines, 0, 6);
assert_eq!(marked, "hello [[CURSOR_HERE]]world");
}
#[test]
fn parse_iso_date_handles_valid_and_invalid() {
assert!(parse_iso_date("2026-09-01").is_some());
assert!(parse_iso_date(" 2026-09-01 ").is_some());
assert!(parse_iso_date("").is_none());
assert!(parse_iso_date("not-a-date").is_none());
}
}