use crossterm::event::{KeyCode, KeyEvent};
use uuid::Uuid;
use super::super::focus::Focus;
use super::super::modal::Modal;
use super::{content_fingerprint, RightPane};
impl super::App {
pub(super) fn open_inner_editor_overview(&mut self) {
let rows = self.build_inner_editor_rows();
self.modal = Modal::InnerEditorOverview { rows, cursor: 0 };
self.status = "Inner Editor · E engage ¶ · C converse · A ambient · F findings · Esc".into();
}
fn build_inner_editor_rows(&self) -> Vec<String> {
use crate::inner_editor::InnerEditorStore;
let ie = &self.cfg.inner_editor;
let mut rows = vec![
format!("Inner Editor — {}", if ie.enabled { "enabled" } else { "disabled" }),
format!(
" tone {} · verbosity {} · praise {}",
ie.persona.tone, ie.persona.verbosity, ie.persona.praise_frequency
),
format!(
" genre-aware {} ({}) · belief stance {}",
ie.persona.genre_aware,
self.cfg.genre.as_deref().unwrap_or("none"),
ie.persona.belief_stance_enabled
),
format!(
"Ambient auto-engage: {}",
if self.ie_auto { "ON — runs on a writing pause" } else { "off (A toggles)" }
),
String::new(),
];
let root = self.store.project_root();
if let Ok(s) = InnerEditorStore::open_for_project(root) {
crate::dayclock::set_boundary(self.cfg.goals.day_boundary);
let day = crate::dayclock::today_key();
let used = s
.llm_calls_today(&day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET)
.unwrap_or(0);
let cap = ie.llm.editor_engagement.max_calls_per_day;
rows.push(format!("Today: {used}/{cap} engagement call(s) (cap is informative)"));
rows.push(String::new());
let findings = s.list_findings().unwrap_or_default();
if findings.is_empty() {
rows.push("No observations yet.".into());
rows.push("Press E to engage the open paragraph.".into());
} else {
rows.push(format!("Recent observations ({} shown):", findings.len().min(12)));
for sf in findings.iter().take(12) {
rows.push(format!(
" {} [{}] {}",
sf.finding.severity.label(),
sf.finding.category.label(),
sf.finding.observation
));
}
}
}
rows.push(String::new());
rows.push("The Editor observes craft; it never edits your prose.".into());
rows
}
pub(super) fn inner_editor_overview_handle_key(&mut self, key: KeyEvent) -> bool {
let n = match &self.modal {
Modal::InnerEditorOverview { rows, .. } => rows.len(),
_ => return false,
};
match key.code {
KeyCode::Esc => {
self.modal = Modal::None;
self.status = "Inner Editor: closed".into();
}
KeyCode::Up => {
if let Modal::InnerEditorOverview { cursor, .. } = &mut self.modal {
*cursor = cursor.saturating_sub(1);
}
}
KeyCode::Down => {
if let Modal::InnerEditorOverview { cursor, .. } = &mut self.modal {
if n > 0 {
*cursor = (*cursor + 1).min(n - 1);
}
}
}
KeyCode::Char('e') | KeyCode::Char('E') => {
self.modal = Modal::None;
self.inner_editor_engage_open_paragraph(false);
}
KeyCode::Char('a') | KeyCode::Char('A') => {
self.toggle_inner_editor_auto();
let rows = self.build_inner_editor_rows();
if let Modal::InnerEditorOverview { rows: r, cursor } = &mut self.modal {
*r = rows;
*cursor = 0;
}
}
KeyCode::Char('f') | KeyCode::Char('F') => self.inner_editor_jump_to_findings(),
KeyCode::Char('c') | KeyCode::Char('C') => {
self.modal = Modal::None;
self.seed_editor_session();
self.right_pane = RightPane::Ai;
self.change_focus(Focus::AiPrompt);
}
_ => {}
}
true
}
pub(super) fn seed_editor_session(&mut self) {
use crate::tui::inference::AiMode;
if !self.cfg.inner_editor.enabled {
self.status = "Inner Editor is disabled (inner_editor.enabled: false)".into();
return;
}
let findings = self.collect_open_editor_findings();
let (prologue, opening) = self.editor_conversation_seed(&findings);
self.seed_editor_chat(prologue, opening);
self.ai_mode = AiMode::EditorConversation;
self.status = if findings.is_empty() {
"Editor scope · ask about this paragraph's craft (F9 to exit)".into()
} else {
format!(
"Editor scope · {} observation(s) queued (F9 to exit)",
findings.len()
)
};
}
fn collect_open_editor_findings(&self) -> Vec<crate::inner_editor::EditorFinding> {
let Some(doc) = self.opened.as_ref() else {
return Vec::new();
};
let id = doc.id;
crate::inner_editor::InnerEditorStore::open_for_project(self.store.project_root())
.ok()
.map(|s| {
s.list_findings()
.unwrap_or_default()
.into_iter()
.filter(|sf| sf.paragraph_id == Some(id))
.map(|sf| sf.finding)
.collect()
})
.unwrap_or_default()
}
fn editor_conversation_seed(
&self,
findings: &[crate::inner_editor::EditorFinding],
) -> (String, String) {
let obs = if findings.is_empty() {
"(no observations recorded on this paragraph yet)".to_string()
} else {
findings
.iter()
.map(|f| format!("- [{} · {}] {}", f.severity.label(), f.category.label(), f.observation))
.collect::<Vec<_>>()
.join("\n")
};
let prologue = format!(
"{EDITOR_SEED_MARKER}You are the Inner Editor — a thoughtful editor discussing your \
observations about the author's open paragraph. You OBSERVE craft; you never \
prescribe, never rewrite, never command (the words \"should\" / \"must\" / \"need \
to\" are not in your vocabulary — only \"I notice\", \"you might consider\", \"this \
could\"). When an observation implies a change, frame it conditionally. Praise must \
be specific and earned. Discuss these observations one at a time, helping the author \
think about whether each applies and what (if anything) they want to do — the choice \
is theirs.\n\nObservations on the open paragraph:\n{obs}",
EDITOR_SEED_MARKER = super::EDITOR_SEED_MARKER,
);
let opening = match findings.first() {
Some(f) => format!("A couple of things I noticed. {}", f.observation),
None => "I'm reading for craft — rhythm, texture, what's working and what could be \
sharper. What would you like to look at?"
.to_string(),
};
(prologue, opening)
}
fn seed_editor_chat(&mut self, prologue: String, opening: String) {
use crate::ai::stream::ChatTurn;
if let Some(i) = self
.chat_history
.iter()
.position(|t| matches!(t, ChatTurn::User(s) if s.starts_with(super::EDITOR_SEED_MARKER)))
{
if self.chat_history.get(i + 1).is_some_and(|t| matches!(t, ChatTurn::Assistant(_))) {
self.chat_history.remove(i + 1);
}
self.chat_history.remove(i);
}
let mut seeded = vec![ChatTurn::User(prologue), ChatTurn::Assistant(opening)];
seeded.append(&mut self.chat_history);
self.chat_history = seeded;
self.chat_history_scroll = 0;
}
fn toggle_inner_editor_auto(&mut self) {
self.ie_auto = !self.ie_auto;
self.ie_last_fp = None;
self.ie_needs_check = false;
self.status = if self.ie_auto {
"Inner Editor ambient auto-engage ON — runs on a writing pause (LLM cost)".into()
} else {
"Inner Editor ambient auto-engage off".into()
};
}
pub(super) fn inner_editor_record_intent_action(&mut self) {
let Some(m) = crate::pane::output::active()
.and_then(|s| s.active().ok())
.unwrap_or_default()
.get(self.output_selected)
.cloned()
else {
return;
};
if m.kind != crate::pane::output::kinds::INNER_EDITOR_OBSERVATION {
return;
}
let Some(cat) = m
.metadata
.get("category")
.and_then(|c| c.as_str())
.and_then(crate::inner_editor::EditorCategory::from_id)
else {
return;
};
let chapter = self.inner_editor_chapter_of(m.source_paragraph_id);
match crate::inner_editor::intent_declare::declare_intent(
self.store.project_root(),
cat,
chapter.as_deref(),
None,
) {
Ok(()) => {
self.status = format!("declared intent — {} won't be re-noted here", cat.label());
if let Some(s) = crate::pane::output::active() {
let _ = s.dismiss(m.id);
}
}
Err(e) => self.status = format!("Inner Editor intent failed: {e}"),
}
}
pub(super) fn record_editor_dismissal(&mut self, m: &crate::pane::output::Message) -> bool {
use crate::inner_editor::InnerEditorStore;
if m.kind != crate::pane::output::kinds::INNER_EDITOR_OBSERVATION {
return false;
}
let Some(cat) = m
.metadata
.get("category")
.and_then(|c| c.as_str())
.and_then(crate::inner_editor::EditorCategory::from_id)
else {
return false;
};
let chapter = self.inner_editor_chapter_of(m.source_paragraph_id).unwrap_or_default();
let Ok(store) = InnerEditorStore::open_for_project(self.store.project_root()) else {
return false;
};
let _ = store.record_dismissal(cat, &chapter);
if let Ok(cands) = store.promotion_candidates(5) {
if cands.iter().any(|c| c.category == cat && c.chapter_id == chapter) {
self.status = format!(
"You've dismissed several ✎ {} observations — `inkhaven inner-editor suggestions` can declare it deliberate",
cat.label()
);
return true;
}
}
false
}
fn inner_editor_chapter_of(&self, paragraph_id: Option<uuid::Uuid>) -> Option<String> {
let pid = paragraph_id?;
let node = self.hierarchy.get(pid)?;
self.hierarchy
.ancestors(node)
.into_iter()
.find(|a| a.kind == crate::store::node::NodeKind::Chapter)
.map(|c| c.id.to_string())
}
fn inner_editor_jump_to_findings(&mut self) {
self.modal = Modal::None;
self.output_filter.source = Some("inner-editor".into());
self.right_pane = RightPane::Output;
self.change_focus(Focus::Ai);
self.status = "Inner Editor findings — Output filtered to inner-editor".into();
}
pub(super) fn inner_editor_engage_open_paragraph(&mut self, ambient: bool) {
if !self.cfg.inner_editor.enabled {
if !ambient {
self.status = "Inner Editor is disabled (inner_editor.enabled: false)".into();
}
return;
}
let Some(doc) = self.opened.as_ref() else {
if !ambient {
self.status = "Inner Editor: no paragraph open".into();
}
return;
};
if doc.content_type.as_deref() == Some("jinja") {
if !ambient {
self.status = "Inner Editor: jinja template paragraphs are skipped \
(style analysis does not apply to templates)"
.into();
}
return;
}
if self
.hierarchy
.get(doc.id)
.is_some_and(super::is_structural_nonprose)
{
if !ambient {
self.status = "Inner Editor: structural paragraphs are skipped \
(code / math / table — not prose)"
.into();
}
return;
}
let id = doc.id;
let prose = doc.textarea.lines().join("\n");
if prose.trim().is_empty() {
if !ambient {
self.status = "Inner Editor: the paragraph is empty".into();
}
return;
}
let n = self.cfg.inner_editor.context.preceding_paragraphs;
let (preceding, chapter_id) =
crate::inner_editor::gather_context(&self.store, &self.hierarchy, id, n)
.map(|g| (g.preceding, g.chapter_id))
.unwrap_or_default();
let want_lang = self.active_prompt_language();
let system_override = self
.resolve_prompt(crate::inner_editor::SYSTEM_PROMPT_NAME, &want_lang, || {
crate::inner_editor::system_prompt(&want_lang).to_string()
})
.template;
let threshold = self.cfg.inner_editor.output.severity_threshold.clone();
let root = self.store.project_root().to_path_buf();
let snapshot_id = self.store.list_snapshots(id).ok().and_then(|s| s.first().map(|x| x.id));
self.ie_engage_para = Some(id);
self.start_bg_job(
super::BgJobKind::InnerEditorEngage,
"inner editor",
move |tx, _cancel| {
let result = run_engagement(
root,
id,
chapter_id,
prose,
preceding,
want_lang,
system_override,
threshold,
snapshot_id,
);
let _ = tx.send(super::BgMsg::Done(result));
},
);
}
pub(super) fn tick_inner_editor(&mut self) {
if !self.cfg.inner_editor.enabled || !self.ie_auto {
return;
}
let fp = self.opened.as_ref().map(content_fingerprint);
if fp != self.ie_last_fp {
self.ie_last_fp = fp;
self.ie_activity_at = fp.map(|_| std::time::Instant::now());
self.ie_needs_check = fp.is_some();
return;
}
if !self.ie_needs_check || !matches!(self.modal, Modal::None) || self.bg_job.is_some() {
return;
}
if fp.is_some() && fp == self.ie_last_engaged_fp {
self.ie_needs_check = false;
return;
}
let cooldown = std::time::Duration::from_secs(self.cfg.inner_editor.engagement.cooldown_seconds);
if let Some(last) = self.ie_last_engage_at {
if last.elapsed() < cooldown {
return;
}
}
let idle = std::time::Duration::from_secs(self.cfg.inner_editor.engagement.idle_threshold_seconds);
if let Some(t) = self.ie_activity_at {
if t.elapsed() >= idle {
self.ie_needs_check = false;
self.ie_last_engaged_fp = fp;
self.ie_last_engage_at = Some(std::time::Instant::now());
self.inner_editor_engage_open_paragraph(true);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_engagement(
project: std::path::PathBuf,
paragraph_id: Uuid,
chapter_id: Option<String>,
prose: String,
preceding: Vec<String>,
language: String,
system_override: String,
threshold: String,
snapshot_id: Option<Uuid>,
) -> std::result::Result<String, String> {
use crate::inner_editor::output::{emit_finding, meets_threshold};
let outcome = crate::inner_editor::engage(crate::inner_editor::EngageInput {
project,
paragraph_id: Some(paragraph_id),
chapter_id,
prose,
preceding,
language,
snapshot_id,
system_override: Some(system_override),
force: false,
})
.map_err(|e| e.to_string())?;
if let Some(s) = crate::pane::output::active() {
if let Ok(msgs) = s.by_kind(crate::pane::output::kinds::INNER_EDITOR_OBSERVATION) {
for m in msgs.iter().filter(|m| m.source_paragraph_id == Some(paragraph_id)) {
let _ = s.dismiss(m.id);
}
}
}
let mut emitted = 0usize;
for f in &outcome.findings {
if meets_threshold(f.severity, &threshold) {
emit_finding(f, Some(paragraph_id));
emitted += 1;
}
}
Ok(emitted.to_string())
}