use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::Backend;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use tokio::sync::mpsc;
use tui_textarea::TextArea;
use uuid::Uuid;
use crate::ai::stream::{ChatTurn as AiTurn, StreamMsg, spawn_chat_stream};
use super::Focus;
use super::chat::ChatTurn;
use super::extract::{self, TargetBook};
use super::facts_tree::FactsTree;
use super::llm;
use super::render;
use super::thread::{self, ResearchThread, ResearchTurn, TurnKind};
use crate::tui::theme::Theme;
pub(super) struct ManualEntry {
pub stage: ManualStage,
pub title: String,
pub body: String,
}
#[derive(PartialEq, Eq)]
pub(super) enum ManualStage {
Title,
Body,
}
#[derive(Clone, Default)]
pub(super) struct ProvMeta {
pub origin: String, pub query: String, pub detail: String, }
pub(super) struct ExtractState {
rx: mpsc::UnboundedReceiver<StreamMsg>,
buf: String,
book: TargetBook,
book_id: Uuid,
target: Option<Uuid>,
command: String,
prov: ProvMeta,
}
pub(super) struct ConfirmationState {
pub title: TextArea<'static>,
pub body: TextArea<'static>,
pub book: TargetBook,
pub book_id: Uuid,
pub target: Option<Uuid>,
pub field: ConfirmField,
pub command: String,
pub prov: ProvMeta,
pub dup_warning: Option<String>,
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub(super) enum ConfirmField {
Title,
Body,
}
pub(super) struct ChatSearch {
pub query: String,
pub current: usize,
}
pub(super) struct TreeInput {
pub kind: TreeInputKind,
pub buf: String,
pub target: Uuid,
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub(super) enum TreeInputKind {
Rename,
NewChapter,
NewSubchapter,
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub(super) enum ClipMode {
Copy,
Move,
}
impl TreeInputKind {
pub(super) fn label(self) -> &'static str {
match self {
TreeInputKind::Rename => "Rename",
TreeInputKind::NewChapter => "New chapter",
TreeInputKind::NewSubchapter => "New subchapter",
}
}
}
pub(super) struct FactCheckState {
facts: Vec<super::factcheck::FactEntry>,
truth_phase: bool,
chunk_idx: usize,
truth_report: String,
rx: Option<mpsc::UnboundedReceiver<StreamMsg>>,
buf: String,
}
pub(super) struct ChainState {
steps: Vec<String>,
current: usize,
accumulated: Vec<String>,
rx: Option<mpsc::UnboundedReceiver<StreamMsg>>,
turn_idx: usize,
}
pub(crate) struct ResearchApp {
pub(super) layout: ProjectLayout,
pub(super) cfg: Config,
pub(super) store: Store,
pub(super) hierarchy: Hierarchy,
pub(super) theme: Theme,
pub(super) thread: ResearchThread,
pub(super) facts_tree: FactsTree,
pub(super) pinned_nodes: Vec<Uuid>,
pub(super) manual: Option<ManualEntry>,
pub(super) tree_input: Option<TreeInput>,
pub(super) pending_delete: Option<Uuid>,
pub(super) tree_clipboard: Option<(Uuid, ClipMode)>,
pub(super) query: TextArea<'static>,
pub(super) prompt_history: Vec<String>,
pub(super) prompt_history_idx: Option<usize>,
pub(super) draft_backup: String,
pub(super) chat_history: Vec<ChatTurn>,
pub(super) chat_scroll: u16,
pub(super) chat_search: Option<ChatSearch>,
stream_rx: Option<mpsc::UnboundedReceiver<StreamMsg>>,
streaming_turn: Option<usize>,
extracting: Option<ExtractState>,
verify_rx: Option<(mpsc::UnboundedReceiver<StreamMsg>, String)>,
chain: Option<ChainState>,
factcheck: Option<FactCheckState>,
pub(super) confirmation: Option<ConfirmationState>,
pub(super) session_cost: f64,
budget_warned: bool,
pub(super) focus: Focus,
pub(super) show_hints: bool,
pub(super) split_ratio: u32,
pub(super) status_message: Option<String>,
ctrl_b_pending: bool,
pub(super) show_help: bool,
should_quit: bool,
}
impl ResearchApp {
pub(crate) fn new(
layout: ProjectLayout,
cfg: Config,
store: Store,
hierarchy: Hierarchy,
thread_name: Option<String>,
) -> Result<ResearchApp> {
let name = thread_name.unwrap_or_else(|| "default".to_string());
let now = chrono::Utc::now().to_rfc3339();
let thread = ResearchThread::open_or_create(&layout, &name, now)?;
let facts_tree = FactsTree::new(&hierarchy);
let pinned_nodes: Vec<Uuid> = thread
.pinned_nodes
.iter()
.filter_map(|s| Uuid::parse_str(s).ok())
.filter(|id| hierarchy.get(*id).is_some())
.collect();
let prompt_history = build_prompt_history(&thread);
let show_hints = cfg.research.show_keybind_hints;
let split_ratio = cfg.research.split_ratio;
let theme = Theme::from_config(&cfg.theme);
Ok(ResearchApp {
layout,
cfg,
store,
hierarchy,
theme,
thread,
facts_tree,
pinned_nodes,
manual: None,
tree_input: None,
pending_delete: None,
tree_clipboard: None,
query: TextArea::default(),
prompt_history,
prompt_history_idx: None,
draft_backup: String::new(),
chat_history: Vec::new(),
chat_scroll: 0,
chat_search: None,
stream_rx: None,
streaming_turn: None,
extracting: None,
verify_rx: None,
chain: None,
factcheck: None,
confirmation: None,
session_cost: 0.0,
budget_warned: false,
focus: Focus::QueryPrompt,
show_hints,
split_ratio,
status_message: None,
ctrl_b_pending: false,
show_help: false,
should_quit: false,
})
}
fn max_pinned(&self) -> usize {
self.cfg.research.max_pinned_nodes.max(1)
}
pub(crate) fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
while !self.should_quit {
self.poll_stream();
self.poll_extraction();
self.poll_verify();
self.poll_chain();
self.poll_factcheck();
terminal.draw(|f| render::render(f, self))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Release {
self.on_key(key);
}
}
}
}
Ok(())
}
fn on_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('q'))
{
self.should_quit = true;
return;
}
if self.show_help {
self.show_help = false;
return;
}
if self.ctrl_b_pending {
self.ctrl_b_pending = false;
if matches!(key.code, KeyCode::Char('h') | KeyCode::Char('H')) {
self.show_help = true;
}
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
self.ctrl_b_pending = true;
return;
}
if self.manual.is_some() {
self.manual_entry_key(key);
return;
}
if self.confirmation.is_some() {
self.confirmation_key(key);
return;
}
if self.tree_input.is_some() {
self.tree_input_key(key);
return;
}
if self.pending_delete.is_some() {
self.delete_confirm_key(key);
return;
}
match key.code {
KeyCode::Tab => {
self.focus = self.focus.next();
return;
}
KeyCode::BackTab => {
self.focus = self.focus.prev();
return;
}
KeyCode::F(10) => {
self.thread.rag_mode = self.thread.rag_mode.next();
let _ = self.thread.save(&self.layout);
self.status_message = Some(format!("RAG: {}", self.thread.rag_mode.label()));
return;
}
_ => {}
}
match self.focus {
Focus::FactsTree => {
if self.facts_tree_key(key) {
return;
}
}
Focus::QueryPrompt => {
self.query_prompt_key(key);
return;
}
Focus::AiChat => {
if self.chat_key(key) {
return;
}
}
Focus::ConfirmationOverlay => {}
}
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Char('?') => self.show_hints = !self.show_hints,
_ => {}
}
}
fn query_prompt_key(&mut self, key: KeyEvent) {
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Enter if alt || key.modifiers.contains(KeyModifiers::CONTROL) => {
self.prompt_history_idx = None;
self.query.insert_newline();
}
KeyCode::Enter => self.submit_query(),
KeyCode::Up => {
let (row, _) = self.query.cursor();
if row == 0 {
self.history_back();
} else {
self.query.move_cursor(tui_textarea::CursorMove::Up);
}
}
KeyCode::Down => {
let (row, _) = self.query.cursor();
let last = self.query.lines().len().saturating_sub(1);
if row >= last {
self.history_forward();
} else {
self.query.move_cursor(tui_textarea::CursorMove::Down);
}
}
KeyCode::Left => self.query.move_cursor(tui_textarea::CursorMove::Back),
KeyCode::Right => self.query.move_cursor(tui_textarea::CursorMove::Forward),
KeyCode::Home => self.query.move_cursor(tui_textarea::CursorMove::Head),
KeyCode::End => self.query.move_cursor(tui_textarea::CursorMove::End),
KeyCode::Esc => {
if self.query_text().trim().is_empty() {
self.focus = Focus::FactsTree;
} else {
self.query = TextArea::default();
self.prompt_history_idx = None;
}
}
_ => {
self.prompt_history_idx = None;
let input: tui_textarea::Input = key.into();
self.query.input_without_shortcuts(input);
}
}
}
fn chat_key(&mut self, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if let Some(search) = self.chat_search.as_mut() {
match key.code {
KeyCode::Esc => self.chat_search = None,
KeyCode::Char('f') if ctrl => self.chat_search = None,
KeyCode::Char('n') => search.current = search.current.wrapping_add(1),
KeyCode::Char('N') => search.current = search.current.wrapping_sub(1),
KeyCode::Backspace => {
search.query.pop();
search.current = 0;
}
KeyCode::Char(c) => {
search.query.push(c);
search.current = 0;
}
_ => {}
}
return true;
}
match key.code {
KeyCode::Char('f') if ctrl => {
self.chat_search = Some(ChatSearch { query: String::new(), current: 0 });
}
KeyCode::Up | KeyCode::Char('k') => self.chat_scroll = self.chat_scroll.saturating_add(1),
KeyCode::Down | KeyCode::Char('j') => self.chat_scroll = self.chat_scroll.saturating_sub(1),
KeyCode::Char('g') => self.chat_scroll = u16::MAX, KeyCode::Char('G') => self.chat_scroll = 0, KeyCode::Esc => self.focus = Focus::QueryPrompt,
_ => return false,
}
true
}
pub(super) fn query_text(&self) -> String {
self.query.lines().join("\n")
}
fn set_query_text(&mut self, text: &str) {
let mut ta = TextArea::default();
ta.insert_str(text);
self.query = ta;
}
fn submit_query(&mut self) {
let text = self.query_text().trim().to_string();
if text.is_empty() {
return;
}
self.query = TextArea::default();
self.prompt_history_idx = None;
self.chat_scroll = 0;
if let Some(cmd) = super::command::parse(&text) {
self.dispatch_command(cmd);
return;
}
self.start_query(text);
}
fn dispatch_command(&mut self, cmd: super::command::Command) {
use super::command::Command;
match cmd {
Command::Clear => {
self.chat_history.clear();
self.chat_scroll = 0;
self.status_message = Some("chat cleared".to_string());
}
Command::Rag(arg) => self.set_rag_mode(arg.as_deref()),
Command::Save(name) => self.save_thread_as(name.as_deref()),
Command::Unknown(name) => {
self.status_message = Some(format!("unknown command: /{name}"));
}
Command::Fact { prompt, path } => {
self.start_extraction(TargetBook::Facts, prompt, path, "/fact")
}
Command::Note { prompt, path } => {
self.start_extraction(TargetBook::Notes, prompt, path, "/note")
}
Command::Goto(path) => self.goto_path(&path),
Command::Diff => self.run_diff(),
Command::Verify => self.run_verify(),
Command::FactCheck => self.start_factcheck(),
Command::Sources => self.run_sources(),
Command::Promote { note, path } => self.start_promote(note, path),
Command::Import(path) => match path {
Some(p) => self.run_import(&p),
None => self.run_imports_list(),
},
Command::Forget(name) => self.run_forget(&name),
Command::Chain(steps) => self.start_chain(steps),
}
}
fn run_sources(&mut self) {
let prov = super::provenance::Provenance::load(&self.layout);
let Some(book_id) = self.facts_tree.root else {
self.status_message = Some("no Facts book".to_string());
return;
};
let mut out = String::new();
let mut n = 0usize;
for id in self.hierarchy.collect_subtree(book_id) {
let Some(node) = self.hierarchy.get(id) else { continue };
if node.kind != crate::store::NodeKind::Paragraph {
continue;
}
let loc = self.hierarchy.slug_path(node);
match prov.for_node(&id.to_string()) {
Some(rec) => out.push_str(&format!("• {loc}\n {}\n", rec.summary())),
None => out.push_str(&format!("• {loc}\n (no recorded source)\n")),
}
n += 1;
}
if n == 0 {
out = "No facts yet.".to_string();
}
self.chat_history
.push(ChatTurn::with_response(format!("[/sources — {n} fact(s)]"), out));
self.chat_scroll = 0;
}
fn run_import(&mut self, path: &str) {
use super::imports;
let p = std::path::Path::new(path);
let text = match imports::read_source(p) {
Ok(t) => t,
Err(e) => {
self.status_message = Some(format!("import: {e}"));
return;
}
};
let chunk_chars = self.cfg.research.import_chunk_chars.max(200);
let chunks = imports::chunk_text(&text, chunk_chars);
if chunks.is_empty() {
self.status_message = Some("import: no text extracted".to_string());
return;
}
let name = imports::source_name(p);
let abs = std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
let now = chrono::Utc::now().to_rfc3339();
let mut doc_ids = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
let meta = serde_json::json!({
"kind": imports::SOURCE_KIND,
"source": abs,
"name": name,
"thread": self.thread.name,
"chunk": i,
"imported_at": now,
});
match self.store.raw().add_document(meta, chunk.as_bytes()) {
Ok(id) => doc_ids.push(id.to_string()),
Err(e) => {
self.status_message = Some(format!("import: embed failed: {e}"));
return;
}
}
}
let mut imports_store = imports::Imports::load(&self.layout);
if let Some(old) = imports_store.sources.get(&name) {
for id in &old.doc_ids {
if let Ok(uuid) = uuid::Uuid::parse_str(id) {
let _ = self.store.raw().delete_document(uuid);
}
}
}
let chunks_n = doc_ids.len();
imports_store.sources.insert(
name.clone(),
imports::ImportedSource {
name: name.clone(),
path: abs,
doc_ids,
thread: self.thread.name.clone(),
imported_at: now,
chunks: chunks_n,
},
);
let _ = imports_store.save(&self.layout);
self.status_message = Some(format!("✓ imported `{name}` — {chunks_n} chunk(s) as a research source"));
}
fn run_imports_list(&mut self) {
let store = super::imports::Imports::load(&self.layout);
let mut out = String::new();
if store.sources.is_empty() {
out.push_str("No documents imported. Use /import <path> (md / txt / pdf).");
} else {
for s in store.sources.values() {
out.push_str(&format!("• {} — {} chunk(s)\n {}\n", s.name, s.chunks, s.path));
}
out.push_str("─────\n/forget <name> removes one.");
}
self.chat_history.push(ChatTurn::with_response(
format!("[/import — {} source(s)]", store.sources.len()),
out,
));
self.chat_scroll = 0;
}
fn run_forget(&mut self, name: &str) {
let key = super::imports::source_name(std::path::Path::new(name));
let mut store = super::imports::Imports::load(&self.layout);
let found = store.sources.remove(name).or_else(|| store.sources.remove(&key));
match found {
Some(src) => {
for id in &src.doc_ids {
if let Ok(uuid) = uuid::Uuid::parse_str(id) {
let _ = self.store.raw().delete_document(uuid);
}
}
let _ = store.save(&self.layout);
self.status_message = Some(format!("forgot `{}` ({} chunk(s))", src.name, src.doc_ids.len()));
}
None => self.status_message = Some(format!("no imported source named `{name}`")),
}
}
fn start_promote(&mut self, note: Option<String>, path: Option<String>) {
let note_path = note.or_else(|| {
self.thread
.turns
.iter()
.rev()
.find(|t| t.kind == TurnKind::NoteInsertion)
.and_then(|t| t.insertion_path.clone())
});
let Some(note_path) = note_path else {
self.status_message =
Some("usage: /promote <notes/path> (no recent note to promote)".to_string());
return;
};
let trimmed = note_path.trim().trim_start_matches('/');
let node = self.hierarchy.find_by_path(trimmed).or_else(|| {
let stripped = trimmed.strip_prefix("notes/").unwrap_or(trimmed);
self.hierarchy.find_by_path(stripped)
});
let Some(node) = node else {
self.status_message = Some(format!("note not found: {note_path}"));
return;
};
let note_id = node.id;
let text = match self.store.get_content(note_id) {
Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).trim().to_string(),
_ => String::new(),
};
if text.is_empty() {
self.status_message = Some("that note is empty".to_string());
return;
}
let prov = ProvMeta {
origin: "promoted".to_string(),
query: String::new(),
detail: note_path.clone(),
};
self.start_extraction_from(TargetBook::Facts, text, None, path, "/promote", prov);
}
fn start_factcheck(&mut self) {
if self.factcheck.is_some() {
self.status_message = Some("a fact-check is already running".to_string());
return;
}
let Some(book_id) = self.facts_tree.root else {
self.status_message = Some("no Facts book".to_string());
return;
};
let facts = super::factcheck::gather_facts(&self.store, &self.hierarchy, book_id);
if facts.is_empty() {
self.status_message = Some("no facts to check — add some first".to_string());
return;
}
self.factcheck = Some(FactCheckState {
facts,
truth_phase: true,
chunk_idx: 0,
truth_report: String::new(),
rx: None,
buf: String::new(),
});
self.factcheck_next_call();
}
fn factcheck_next_call(&mut self) {
let (lang, _note) = crate::prose::resolve_prose_language(None, &self.cfg.language);
let language = super::extract::language_name(&lang);
let Some(fc) = self.factcheck.as_mut() else { return };
let total = fc.facts.len();
let chunk_size = super::factcheck::TRUTH_CHUNK;
let n_chunks = total.div_ceil(chunk_size);
let (system, user, status) = if fc.truth_phase && fc.chunk_idx < n_chunks {
let base = fc.chunk_idx * chunk_size;
let chunk: Vec<&super::factcheck::FactEntry> =
fc.facts[base..(base + chunk_size).min(total)].iter().collect();
(
super::factcheck::truth_system(language),
super::factcheck::truth_user(&chunk, base),
format!("fact-check: truth {}/{n_chunks}…", fc.chunk_idx + 1),
)
} else {
fc.truth_phase = false;
(
super::factcheck::consistency_system(language),
super::factcheck::consistency_user(&fc.facts),
"fact-check: consistency…".to_string(),
)
};
let ai = match crate::ai::AiClient::from_config(&self.cfg.llm) {
Ok(a) => a,
Err(e) => {
self.status_message = Some(format!("no LLM provider: {e}"));
self.factcheck = None;
return;
}
};
let (model, _env) = match ai.resolve_provider(&self.cfg.llm, None) {
Ok(m) => m,
Err(e) => {
self.status_message = Some(format!("provider error: {e}"));
self.factcheck = None;
return;
}
};
let rx = spawn_chat_stream(ai.client.clone(), model.to_string(), Some(system), Vec::new(), user, llm::CATEGORY);
if let Some(fc) = self.factcheck.as_mut() {
fc.rx = Some(rx);
fc.buf.clear();
}
self.status_message = Some(status);
}
fn poll_factcheck(&mut self) {
let Some(fc) = self.factcheck.as_mut() else { return };
let Some(rx) = fc.rx.as_mut() else { return };
let mut done = false;
loop {
match rx.try_recv() {
Ok(StreamMsg::Token(t)) => fc.buf.push_str(&t),
Ok(StreamMsg::Done) | Err(mpsc::error::TryRecvError::Disconnected) => {
done = true;
break;
}
Ok(StreamMsg::Error(e)) => {
self.status_message = Some(format!("fact-check error: {e}"));
self.factcheck = None;
return;
}
Err(mpsc::error::TryRecvError::Empty) => break,
}
}
if !done {
return;
}
let (was_truth, report): (bool, Option<(usize, String)>) = {
let fc = self.factcheck.as_mut().unwrap();
fc.rx = None;
let out = std::mem::take(&mut fc.buf);
if fc.truth_phase {
fc.truth_report.push_str(out.trim());
fc.truth_report.push('\n');
fc.chunk_idx += 1;
(true, None)
} else {
let total = fc.facts.len();
let report = format!(
"[/factcheck — {total} fact(s)]\n\n── Factual accuracy ──\n{}\n\n── Mutual consistency ──\n{}\n",
fc.truth_report.trim(),
out.trim(),
);
(false, Some((total, report)))
}
};
if was_truth {
self.factcheck_next_call();
} else if let Some((total, report)) = report {
self.chat_history.push(ChatTurn::with_response("/factcheck".to_string(), report));
self.chat_scroll = 0;
self.factcheck = None;
self.status_message = Some(format!("fact-check complete · {total} fact(s)"));
}
}
fn start_chain(&mut self, steps: Vec<String>) {
if self.chain.is_some() {
self.status_message = Some("a chain is already running".to_string());
return;
}
if steps.is_empty() {
self.status_message = Some("usage: /chain q1 → q2 → q3".to_string());
return;
}
self.chain = Some(ChainState { steps, current: 0, accumulated: Vec::new(), rx: None, turn_idx: 0 });
self.start_chain_step();
}
fn start_chain_step(&mut self) {
let Some(chain) = self.chain.as_mut() else { return };
let i = chain.current;
let total = chain.steps.len();
let step = chain.steps[i].clone();
let accumulated = chain.accumulated.join("\n\n");
let ai = match crate::ai::AiClient::from_config(&self.cfg.llm) {
Ok(a) => a,
Err(e) => {
self.status_message = Some(format!("no LLM provider: {e}"));
self.chain = None;
return;
}
};
let (model, _env) = match ai.resolve_provider(&self.cfg.llm, None) {
Ok(m) => m,
Err(e) => {
self.status_message = Some(format!("provider error: {e}"));
self.chain = None;
return;
}
};
let (rag, sources) = super::rag::build_context(
&self.store,
&self.cfg,
&self.hierarchy,
self.facts_tree.root,
&self.pinned_nodes,
self.thread.rag_mode,
&step,
);
let mut system = llm::system_prompt(self.thread.rag_mode, rag.as_deref());
if i > 0 {
system.push_str(&format!(
"\n\nPrevious research (step {}/{}):\n{}",
i,
total,
accumulated
));
}
let mut turn = ChatTurn::new(format!("[Step {}/{}] {}", i + 1, total, step));
turn.streaming = true;
turn.sources = sources;
self.chat_history.push(turn);
let turn_idx = self.chat_history.len() - 1;
self.chat_scroll = 0;
let rx = spawn_chat_stream(
ai.client.clone(),
model.to_string(),
Some(system),
Vec::new(),
step,
llm::CATEGORY,
);
if let Some(chain) = self.chain.as_mut() {
chain.rx = Some(rx);
chain.turn_idx = turn_idx;
}
self.status_message = Some(format!("[Step {}/{} running…]", i + 1, total));
}
fn poll_chain(&mut self) {
let Some(chain) = self.chain.as_mut() else { return };
let Some(rx) = chain.rx.as_mut() else { return };
let idx = chain.turn_idx;
let mut done = false;
loop {
match rx.try_recv() {
Ok(StreamMsg::Token(t)) => {
if let Some(turn) = self.chat_history.get_mut(idx) {
turn.response.push_str(&t);
}
}
Ok(StreamMsg::Done) | Err(mpsc::error::TryRecvError::Disconnected) => {
done = true;
break;
}
Ok(StreamMsg::Error(e)) => {
if let Some(turn) = self.chat_history.get_mut(idx) {
turn.response.push_str(&format!("\n[error: {e}]"));
}
done = true;
break;
}
Err(mpsc::error::TryRecvError::Empty) => break,
}
}
if !done {
return;
}
let response = self.chat_history.get(idx).map(|t| t.response.clone()).unwrap_or_default();
if let Some(turn) = self.chat_history.get_mut(idx) {
turn.streaming = false;
let cost = llm::estimate_cost(&turn.prompt, &turn.response);
turn.cost = cost;
self.session_cost += cost;
}
let Some(chain) = self.chain.as_mut() else { return };
chain.rx = None;
chain.accumulated.push(response);
chain.current += 1;
if chain.current < chain.steps.len() {
self.start_chain_step();
} else {
self.chain = None;
self.status_message = Some("chain complete".to_string());
}
}
fn set_rag_mode(&mut self, arg: Option<&str>) {
use super::thread::RagMode;
let mode = match arg.map(|a| a.trim().to_ascii_lowercase()) {
Some(a) if a == "facts" || a == "facts-only" || a == "factsonly" => Some(RagMode::FactsOnly),
Some(a) if a == "full" || a == "full-only" || a == "fullonly" => Some(RagMode::FullOnly),
Some(a) if a == "facts+full" || a == "both" => Some(RagMode::FactsPlusFull),
Some(_) => None,
None => Some(self.thread.rag_mode.next()),
};
match mode {
Some(m) => {
self.thread.rag_mode = m;
let _ = self.thread.save(&self.layout);
self.status_message = Some(format!("RAG: {}", m.label()));
}
None => self.status_message = Some("usage: /rag [facts+full|facts|full]".to_string()),
}
}
fn save_thread_as(&mut self, name: Option<&str>) {
if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) {
let old_slug = self.thread.name.clone();
self.thread.display_name = name.to_string();
self.thread.name = thread::thread_slug(name);
if self.thread.save(&self.layout).is_ok() && self.thread.name != old_slug {
let _ = thread::delete_thread(&self.layout, &old_slug);
}
self.status_message = Some(format!("saved as `{name}`"));
} else {
let _ = self.thread.save(&self.layout);
self.status_message = Some("thread saved".to_string());
}
}
fn start_query(&mut self, prompt: String) {
if self.stream_rx.is_some() {
self.status_message = Some("a query is still streaming…".to_string());
return;
}
let ai = match crate::ai::AiClient::from_config(&self.cfg.llm) {
Ok(a) => a,
Err(e) => {
self.chat_history
.push(ChatTurn::with_response(prompt, format!("[no LLM provider: {e}]")));
return;
}
};
let (model, _env) = match ai.resolve_provider(&self.cfg.llm, None) {
Ok(m) => m,
Err(e) => {
self.chat_history
.push(ChatTurn::with_response(prompt, format!("[provider error: {e}]")));
return;
}
};
let history: Vec<AiTurn> = self
.chat_history
.iter()
.flat_map(|t| {
[AiTurn::User(t.prompt.clone()), AiTurn::Assistant(t.response.clone())]
})
.collect();
let (rag, sources) = super::rag::build_context(
&self.store,
&self.cfg,
&self.hierarchy,
self.facts_tree.root,
&self.pinned_nodes,
self.thread.rag_mode,
&prompt,
);
let system = llm::system_prompt(self.thread.rag_mode, rag.as_deref());
let mut turn = ChatTurn::new(prompt.clone());
turn.streaming = true;
turn.sources = sources;
self.chat_history.push(turn);
self.streaming_turn = Some(self.chat_history.len() - 1);
let rx = spawn_chat_stream(
ai.client.clone(),
model.to_string(),
Some(system),
history,
prompt,
llm::CATEGORY,
);
self.stream_rx = Some(rx);
}
fn poll_stream(&mut self) {
let Some(rx) = self.stream_rx.as_mut() else { return };
let Some(idx) = self.streaming_turn else { return };
let mut done = false;
loop {
match rx.try_recv() {
Ok(StreamMsg::Token(t)) => {
if let Some(turn) = self.chat_history.get_mut(idx) {
turn.response.push_str(&t);
}
}
Ok(StreamMsg::Done) => {
done = true;
break;
}
Ok(StreamMsg::Error(e)) => {
if let Some(turn) = self.chat_history.get_mut(idx) {
turn.response.push_str(&format!("\n[error: {e}]"));
}
done = true;
break;
}
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
done = true;
break;
}
}
}
if done {
self.finish_stream(idx);
}
}
fn finish_stream(&mut self, idx: usize) {
self.stream_rx = None;
self.streaming_turn = None;
let (prompt, response) = match self.chat_history.get_mut(idx) {
Some(turn) => {
turn.streaming = false;
let cost = llm::estimate_cost(&turn.prompt, &turn.response);
turn.cost = cost;
self.session_cost += cost;
(turn.prompt.clone(), turn.response.clone())
}
None => return,
};
let now = chrono::Utc::now().to_rfc3339();
let id = uuid::Uuid::now_v7().to_string();
let cost = llm::estimate_cost(&prompt, &response);
let _ = self.thread.push_turn(
ResearchTurn::query(id, prompt, response, cost, now),
&self.layout,
);
self.rebuild_prompt_history();
self.maybe_warn_budget();
}
fn maybe_warn_budget(&mut self) {
let warn = self.cfg.research.session_budget_warn;
if !self.budget_warned && warn > 0.0 && self.session_cost > warn {
self.budget_warned = true;
self.status_message =
Some(format!("session cost ~${:.3} passed the ${warn:.2} budget note", self.session_cost));
}
}
fn goto_path(&mut self, path: &str) {
let trimmed = path.trim().trim_start_matches('/');
let id = self
.hierarchy
.find_by_path(trimmed)
.or_else(|| {
let stripped = trimmed.strip_prefix("facts/").unwrap_or(trimmed);
self.hierarchy.find_by_path(stripped)
})
.map(|n| n.id);
match id {
Some(id) if self.facts_tree.reveal(&self.hierarchy, id) => {
self.focus = Focus::FactsTree;
self.status_message = Some(format!("→ {path}"));
}
_ => self.status_message = Some(format!("Path not found: {path}")),
}
}
fn run_diff(&mut self) {
let diff_top_n = self.cfg.research.diff_top_n.max(1);
let Some(response) = self.chat_history.last().map(|t| t.response.clone()) else {
self.status_message = Some("no research response to compare".to_string());
return;
};
let Some(book_id) = self.facts_tree.root else {
self.status_message = Some("no Facts book".to_string());
return;
};
let passages = crate::book_rag::retrieval::retrieve(
&self.store,
&self.hierarchy,
&self.cfg.book_rag,
book_id,
&response,
)
.unwrap_or_default();
let hits: Vec<_> = passages
.into_iter()
.filter(|p| p.is_hit && !self.pinned_nodes.contains(&p.id))
.take(diff_top_n)
.collect();
let mut out = String::new();
if hits.is_empty() {
out.push_str("No similar facts in your corpus yet.");
} else {
for p in &hits {
let excerpt: String = p.body.chars().take(160).collect();
out.push_str(&format!("{:.2} {}\n {}\n\n", p.score, p.breadcrumb, excerpt.trim()));
}
out.push_str("─────\nUse /fact to add a new entry, or /goto <path> to open an existing one.");
}
self.chat_history.push(ChatTurn::with_response(
format!("[/diff — top {diff_top_n} similar facts]"),
out,
));
self.chat_scroll = 0;
}
fn run_verify(&mut self) {
let min_words = self.cfg.research.verify_min_sentence_words.max(1);
if self.verify_rx.is_some() {
self.status_message = Some("a verification is already running".to_string());
return;
}
let Some(response) = self.chat_history.last().map(|t| t.response.clone()) else {
self.status_message = Some("no research response to verify".to_string());
return;
};
let claims = super::verify::extract_claims(&response, min_words);
if claims.is_empty() {
self.status_message = Some("no specific claims found to verify".to_string());
return;
}
let ai = match crate::ai::AiClient::from_config(&self.cfg.llm) {
Ok(a) => a,
Err(e) => {
self.status_message = Some(format!("no LLM provider: {e}"));
return;
}
};
let (model, _env) = match ai.resolve_provider(&self.cfg.llm, None) {
Ok(m) => m,
Err(e) => {
self.status_message = Some(format!("provider error: {e}"));
return;
}
};
let rx = spawn_chat_stream(
ai.client.clone(),
model.to_string(),
Some(super::verify::PROBE_SYSTEM.to_string()),
Vec::new(),
super::verify::probe_user(&claims),
llm::CATEGORY,
);
self.verify_rx = Some((rx, String::new()));
self.status_message = Some(format!("Verifying {} claim(s)…", claims.len()));
}
fn poll_verify(&mut self) {
let Some((rx, buf)) = self.verify_rx.as_mut() else { return };
let mut done = false;
loop {
match rx.try_recv() {
Ok(StreamMsg::Token(t)) => buf.push_str(&t),
Ok(StreamMsg::Done) | Err(mpsc::error::TryRecvError::Disconnected) => {
done = true;
break;
}
Ok(StreamMsg::Error(e)) => {
self.status_message = Some(format!("verify error: {e}"));
self.verify_rx = None;
return;
}
Err(mpsc::error::TryRecvError::Empty) => break,
}
}
if done {
let (_, buf) = self.verify_rx.take().unwrap();
let mut out = String::new();
for line in buf.lines() {
match super::verify::Confidence::parse(line) {
Some(super::verify::Confidence::Low) => out.push_str(&format!("⚠ {}\n", line.trim())),
Some(_) => out.push_str(&format!("{}\n", line.trim())),
None => {
if !line.trim().is_empty() {
out.push_str(&format!("{}\n", line.trim()));
}
}
}
}
self.chat_history.push(ChatTurn::with_response("[/verify — claim confidence]".to_string(), out));
self.chat_scroll = 0;
self.status_message = None;
}
}
fn system_book_id(&self, tag: &str) -> Option<Uuid> {
self.hierarchy
.children_of(None)
.into_iter()
.find(|n| {
n.kind == crate::store::NodeKind::Book && n.system_tag.as_deref() == Some(tag)
})
.map(|n| n.id)
}
fn start_extraction(
&mut self,
book: TargetBook,
prompt: Option<String>,
path: Option<String>,
command_name: &str,
) {
let Some(last) = self.chat_history.last() else {
self.status_message = Some("no research response yet — ask a question first".to_string());
return;
};
let research = last.response.clone();
let query = last.prompt.clone();
let prov = if last.sources.is_empty() {
ProvMeta { origin: "model".to_string(), query, detail: String::new() }
} else {
ProvMeta { origin: "document".to_string(), query, detail: last.sources.join(", ") }
};
self.start_extraction_from(book, research, prompt, path, command_name, prov);
}
fn start_extraction_from(
&mut self,
book: TargetBook,
research: String,
prompt: Option<String>,
path: Option<String>,
command_name: &str,
prov: ProvMeta,
) {
if self.extracting.is_some() || self.confirmation.is_some() {
self.status_message = Some("finish the current extraction first".to_string());
return;
}
if research.trim().is_empty() {
self.status_message = Some("nothing to extract (empty source)".to_string());
return;
}
let Some(book_id) = self.system_book_id(book.system_tag()) else {
self.status_message = Some(format!("no {} book in this project", book.label()));
return;
};
let target = self.resolve_insertion_target(book, path.as_deref());
let ai = match crate::ai::AiClient::from_config(&self.cfg.llm) {
Ok(a) => a,
Err(e) => {
self.status_message = Some(format!("no LLM provider: {e}"));
return;
}
};
let (model, _env) = match ai.resolve_provider(&self.cfg.llm, None) {
Ok(m) => m,
Err(e) => {
self.status_message = Some(format!("provider error: {e}"));
return;
}
};
let instruction = prompt.unwrap_or_else(|| extract::default_instruction(book).to_string());
let (lang, _note) = crate::prose::resolve_prose_language(None, &self.cfg.language);
let language = extract::language_name(&lang);
let system = extract::system_prompt(book, language, &instruction, &research);
let rx = spawn_chat_stream(
ai.client.clone(),
model.to_string(),
Some(system),
Vec::new(),
"Produce the entry as specified.".to_string(),
llm::CATEGORY,
);
self.extracting = Some(ExtractState {
rx,
buf: String::new(),
book,
book_id,
target,
command: format!("{command_name} \"{instruction}\""),
prov,
});
self.status_message = Some(format!("Extracting {}…", book.label()));
}
fn resolve_insertion_target(&self, book: TargetBook, path: Option<&str>) -> Option<Uuid> {
if let Some(p) = path {
let trimmed = p.trim().trim_start_matches('/');
if let Some(node) = self.hierarchy.find_by_path(trimmed) {
return Some(node.id);
}
let stripped = trimmed
.strip_prefix("facts/")
.or_else(|| trimmed.strip_prefix("notes/"))
.unwrap_or(trimmed);
if let Some(node) = self.hierarchy.find_by_path(stripped) {
return Some(node.id);
}
return None;
}
match book {
TargetBook::Facts => self.facts_tree.selected(),
TargetBook::Notes => None,
}
}
fn poll_extraction(&mut self) {
let Some(ex) = self.extracting.as_mut() else { return };
let mut done = false;
loop {
match ex.rx.try_recv() {
Ok(StreamMsg::Token(t)) => ex.buf.push_str(&t),
Ok(StreamMsg::Done) | Err(mpsc::error::TryRecvError::Disconnected) => {
done = true;
break;
}
Ok(StreamMsg::Error(e)) => {
self.status_message = Some(format!("extraction error: {e}"));
self.extracting = None;
return;
}
Err(mpsc::error::TryRecvError::Empty) => break,
}
}
if done {
let ex = self.extracting.take().unwrap();
let parsed = extract::parse(&ex.buf);
let mut title = TextArea::default();
title.insert_str(&parsed.title);
let mut body = TextArea::default();
body.insert_str(&parsed.text);
self.confirmation = Some(ConfirmationState {
title,
body,
book: ex.book,
book_id: ex.book_id,
target: ex.target,
field: ConfirmField::Title,
command: ex.command,
prov: ex.prov,
dup_warning: None,
});
self.focus = Focus::ConfirmationOverlay;
self.status_message = None;
}
}
fn confirmation_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Enter | KeyCode::Char('s')) {
self.confirm_insertion();
return;
}
match key.code {
KeyCode::Esc => {
self.confirmation = None;
self.focus = Focus::QueryPrompt;
self.status_message = Some("discarded".to_string());
}
KeyCode::Tab => {
if let Some(c) = self.confirmation.as_mut() {
c.field = match c.field {
ConfirmField::Title => ConfirmField::Body,
ConfirmField::Body => ConfirmField::Title,
};
}
}
KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End | KeyCode::Up | KeyCode::Down => {
use tui_textarea::CursorMove;
let mv = match key.code {
KeyCode::Left => CursorMove::Back,
KeyCode::Right => CursorMove::Forward,
KeyCode::Home => CursorMove::Head,
KeyCode::End => CursorMove::End,
KeyCode::Up => CursorMove::Up,
_ => CursorMove::Down,
};
if let Some(c) = self.confirmation.as_mut() {
match c.field {
ConfirmField::Title => c.title.move_cursor(mv),
ConfirmField::Body => c.body.move_cursor(mv),
}
}
}
_ => {
if let Some(c) = self.confirmation.as_mut() {
let input: tui_textarea::Input = key.into();
match c.field {
ConfirmField::Title => {
if key.code != KeyCode::Enter {
c.title.input_without_shortcuts(input);
}
}
ConfirmField::Body => {
c.body.input_without_shortcuts(input);
}
}
}
}
}
}
fn confirm_insertion(&mut self) {
let (title, body) = match &self.confirmation {
Some(c) => (c.title.lines().join(" "), c.body.lines().join("\n")),
None => return,
};
if body.trim().is_empty() {
self.status_message =
Some("fact body is empty — type it, or Esc to cancel".to_string());
return;
}
let already_warned = self.confirmation.as_ref().is_some_and(|c| c.dup_warning.is_some());
let is_facts = self.confirmation.as_ref().is_some_and(|c| c.book == TargetBook::Facts);
if is_facts && !already_warned {
if let Some(dup) = self.find_near_duplicate(&body) {
if let Some(c) = self.confirmation.as_mut() {
c.dup_warning =
Some(format!("similar to {dup} · Ctrl+S again to insert anyway"));
}
self.status_message = Some(format!("near-duplicate of {dup}"));
return;
}
}
let c = self.confirmation.take().unwrap();
match super::insert::insert_paragraph(
&self.store,
&self.cfg,
&self.hierarchy,
c.book_id,
c.target,
&title,
&body,
) {
Ok(new_id) => {
self.reload_hierarchy();
if c.book == TargetBook::Facts {
let _ = self.facts_tree.reveal(&self.hierarchy, new_id);
}
let path = self
.hierarchy
.get(new_id)
.map(|n| self.hierarchy.slug_path(n))
.unwrap_or_default();
let kind = match c.book {
TargetBook::Facts => TurnKind::FactInsertion,
TargetBook::Notes => TurnKind::NoteInsertion,
};
let now = chrono::Utc::now().to_rfc3339();
let id = Uuid::now_v7().to_string();
let _ = self.thread.push_turn(
ResearchTurn::insertion(
id,
kind,
c.command,
title.trim().to_string(),
body,
path.clone(),
c.book.label().to_string(),
now,
),
&self.layout,
);
if c.book == TargetBook::Facts {
let now2 = chrono::Utc::now().to_rfc3339();
super::provenance::Provenance::record(
&self.layout,
&new_id.to_string(),
super::provenance::SourceRecord::new(
&c.prov.origin,
&c.prov.detail,
&c.prov.query,
&self.thread.name,
now2,
),
);
}
self.rebuild_prompt_history();
self.status_message = Some(format!("✓ Inserted: '{}' → {path}", title.trim()));
}
Err(e) => self.status_message = Some(format!("insert failed: {e}")),
}
self.focus = Focus::QueryPrompt;
}
fn find_near_duplicate(&self, body: &str) -> Option<String> {
let book_id = self.facts_tree.root?;
let threshold = self.cfg.research.dedup_warn_score;
let passages = crate::book_rag::retrieval::retrieve(
&self.store,
&self.hierarchy,
&self.cfg.book_rag,
book_id,
body,
)
.ok()?;
passages
.into_iter()
.find(|p| p.is_hit && p.score >= threshold)
.map(|p| p.breadcrumb)
}
fn rebuild_prompt_history(&mut self) {
self.prompt_history = build_prompt_history(&self.thread);
for turn in self.chat_history.iter().rev() {
if !self.prompt_history.iter().any(|p| p == &turn.prompt) {
self.prompt_history.insert(0, turn.prompt.clone());
}
}
}
fn history_back(&mut self) {
if self.prompt_history.is_empty() {
return;
}
let next = match self.prompt_history_idx {
None => {
self.draft_backup = self.query_text();
0
}
Some(i) => (i + 1).min(self.prompt_history.len() - 1),
};
self.prompt_history_idx = Some(next);
let text = self.prompt_history[next].clone();
self.set_query_text(&text);
}
fn history_forward(&mut self) {
match self.prompt_history_idx {
Some(0) | None => {
self.prompt_history_idx = None;
let draft = self.draft_backup.clone();
self.set_query_text(&draft);
}
Some(i) => {
let idx = i - 1;
self.prompt_history_idx = Some(idx);
let text = self.prompt_history[idx].clone();
self.set_query_text(&text);
}
}
}
fn facts_tree_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.facts_tree.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.facts_tree.move_down(),
KeyCode::Char('g') => self.facts_tree.to_top(),
KeyCode::Char('G') => self.facts_tree.to_bottom(),
KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter => {
self.facts_tree.step_in(&self.hierarchy)
}
KeyCode::Left | KeyCode::Char('h') => self.facts_tree.step_out(&self.hierarchy),
KeyCode::Char('n') => {
self.manual = Some(ManualEntry {
stage: ManualStage::Title,
title: String::new(),
body: String::new(),
});
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => self.toggle_pin(),
KeyCode::Char('R') => self.tree_rename_start(),
KeyCode::Char('c') => self.tree_add_start(TreeInputKind::NewChapter),
KeyCode::Char('s') => self.tree_add_start(TreeInputKind::NewSubchapter),
KeyCode::Char('-') => self.tree_delete_start(false),
KeyCode::Char('D') => self.tree_delete_start(true),
KeyCode::Char('K') => self.tree_move(true),
KeyCode::Char('J') => self.tree_move(false),
KeyCode::Char('y') => self.tree_yank(ClipMode::Copy),
KeyCode::Char('x') => self.tree_yank(ClipMode::Move),
KeyCode::Char('p') => self.tree_paste(),
_ => return false,
}
true
}
fn toggle_pin(&mut self) {
let Some(id) = self.facts_tree.selected() else { return };
if let Some(pos) = self.pinned_nodes.iter().position(|p| *p == id) {
self.pinned_nodes.remove(pos);
self.status_message = Some("unpinned".to_string());
} else if self.pinned_nodes.len() >= self.max_pinned() {
self.status_message =
Some(format!("Max {} nodes pinned — unpin one first", self.max_pinned()));
return;
} else {
self.pinned_nodes.push(id);
self.status_message = Some(format!("pinned ({}/{})", self.pinned_nodes.len(), self.max_pinned()));
}
self.persist_pins();
}
fn persist_pins(&mut self) {
self.thread.pinned_nodes = self.pinned_nodes.iter().map(|u| u.to_string()).collect();
let _ = self.thread.save(&self.layout);
}
fn manual_entry_key(&mut self, key: KeyEvent) {
let Some(m) = self.manual.as_mut() else { return };
match m.stage {
ManualStage::Title => match key.code {
KeyCode::Esc => self.manual = None,
KeyCode::Enter => {
if !m.title.trim().is_empty() {
m.stage = ManualStage::Body;
}
}
KeyCode::Backspace => {
m.title.pop();
}
KeyCode::Char(c) => m.title.push(c),
_ => {}
},
ManualStage::Body => {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
self.save_manual_entry();
return;
}
match key.code {
KeyCode::Esc => self.manual = None,
KeyCode::Enter => m.body.push('\n'),
KeyCode::Backspace => {
m.body.pop();
}
KeyCode::Char(c) => m.body.push(c),
_ => {}
}
}
}
}
fn save_manual_entry(&mut self) {
let Some(m) = self.manual.take() else { return };
let Some(book_id) = self.facts_tree.root else {
self.status_message = Some("no Facts book to insert into".to_string());
return;
};
let target = self.facts_tree.selected();
match super::insert::insert_paragraph(
&self.store,
&self.cfg,
&self.hierarchy,
book_id,
target,
&m.title,
&m.body,
) {
Ok(new_id) => {
self.reload_hierarchy();
let _ = self.facts_tree.reveal(&self.hierarchy, new_id);
super::provenance::Provenance::record(
&self.layout,
&new_id.to_string(),
super::provenance::SourceRecord::new(
"manual",
"",
"",
&self.thread.name,
chrono::Utc::now().to_rfc3339(),
),
);
self.status_message = Some(format!("✓ added fact: {}", m.title.trim()));
}
Err(e) => self.status_message = Some(format!("insert failed: {e}")),
}
}
pub(super) fn reload_hierarchy(&mut self) {
if let Ok(h) = Hierarchy::load(&self.store) {
self.hierarchy = h;
self.facts_tree.rebuild(&self.hierarchy);
}
}
fn tree_rename_start(&mut self) {
let Some(id) = self.facts_tree.selected() else { return };
let title = self.hierarchy.get(id).map(|n| n.title.clone()).unwrap_or_default();
self.tree_input = Some(TreeInput { kind: TreeInputKind::Rename, buf: title, target: id });
}
fn tree_add_start(&mut self, kind: TreeInputKind) {
let parent = match kind {
TreeInputKind::NewChapter => self.facts_tree.root,
TreeInputKind::NewSubchapter => self.enclosing_chapter(),
TreeInputKind::Rename => return,
};
match parent {
Some(p) => self.tree_input = Some(TreeInput { kind, buf: String::new(), target: p }),
None => {
self.status_message = Some("no chapter to nest under — add a chapter first".to_string())
}
}
}
fn enclosing_chapter(&self) -> Option<Uuid> {
use crate::store::NodeKind;
let mut cur = self.facts_tree.selected();
while let Some(id) = cur {
let node = self.hierarchy.get(id)?;
if node.kind == NodeKind::Chapter {
return Some(id);
}
cur = node.parent_id;
}
None
}
fn tree_input_key(&mut self, key: KeyEvent) {
let Some(ti) = self.tree_input.as_mut() else { return };
match key.code {
KeyCode::Esc => self.tree_input = None,
KeyCode::Enter => {
let name = ti.buf.trim().to_string();
if name.is_empty() {
self.tree_input = None;
return;
}
let (kind, target) = (ti.kind, ti.target);
self.tree_input = None;
self.tree_input_commit(kind, target, &name);
}
KeyCode::Backspace => {
ti.buf.pop();
}
KeyCode::Char(c) => ti.buf.push(c),
_ => {}
}
}
fn tree_input_commit(&mut self, kind: TreeInputKind, target: Uuid, name: &str) {
use crate::store::{InsertPosition, NodeKind};
let result: Result<Option<Uuid>, String> = match kind {
TreeInputKind::Rename => self
.store
.rename_node(&self.hierarchy, target, name)
.map(|_| Some(target))
.map_err(|e| e.to_string()),
TreeInputKind::NewChapter | TreeInputKind::NewSubchapter => {
let nk = if kind == TreeInputKind::NewChapter {
NodeKind::Chapter
} else {
NodeKind::Subchapter
};
let parent = self.hierarchy.get(target).cloned();
match parent {
Some(p) => self
.store
.create_node(&self.cfg, &self.hierarchy, nk, name, Some(&p), None, InsertPosition::End)
.map(|n| Some(n.id))
.map_err(|e| e.to_string()),
None => Err("parent missing".to_string()),
}
}
};
match result {
Ok(new_id) => {
self.reload_hierarchy();
if let Some(id) = new_id {
let _ = self.facts_tree.reveal(&self.hierarchy, id);
}
self.status_message = Some(format!("{} ✓", kind.label()));
}
Err(e) => self.status_message = Some(format!("{}: {e}", kind.label())),
}
}
fn tree_delete_start(&mut self, branch: bool) {
use crate::store::NodeKind;
let Some(id) = self.facts_tree.selected() else { return };
let Some(node) = self.hierarchy.get(id) else { return };
if Some(id) == self.facts_tree.root {
self.status_message = Some("can't delete the Facts book".to_string());
return;
}
let is_para = node.kind == NodeKind::Paragraph;
if branch && is_para {
self.status_message = Some("press - to delete a paragraph".to_string());
return;
}
if !branch && !is_para {
self.status_message = Some("press D to delete a branch".to_string());
return;
}
self.pending_delete = Some(id);
let title = node.title.clone();
self.status_message = Some(format!("delete `{title}`? y / N"));
}
fn delete_confirm_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => self.tree_delete_commit(),
_ => {
self.pending_delete = None;
self.status_message = Some("delete cancelled".to_string());
}
}
}
fn tree_delete_commit(&mut self) {
use crate::store::NodeKind;
let Some(id) = self.pending_delete.take() else { return };
let Some(node) = self.hierarchy.get(id).cloned() else { return };
let ids = self.hierarchy.collect_subtree(id);
let fs_rel = if node.kind == NodeKind::Paragraph {
node.file.as_ref().map(std::path::PathBuf::from).unwrap_or_default()
} else {
self.hierarchy.fs_path(&node, &self.layout)
};
self.pinned_nodes.retain(|p| !ids.contains(p));
self.persist_pins();
match self.store.delete_subtree(&fs_rel, &ids) {
Ok(()) => {
self.reload_hierarchy();
self.status_message = Some(format!("deleted `{}`", node.title));
}
Err(e) => self.status_message = Some(format!("delete failed: {e}")),
}
}
fn tree_yank(&mut self, mode: ClipMode) {
let Some(id) = self.facts_tree.selected() else { return };
if Some(id) == self.facts_tree.root {
self.status_message = Some("can't copy/move the Facts book".to_string());
return;
}
self.tree_clipboard = Some((id, mode));
let title = self.hierarchy.get(id).map(|n| n.title.clone()).unwrap_or_default();
self.status_message = Some(match mode {
ClipMode::Copy => format!("yanked `{title}` — p to paste a copy"),
ClipMode::Move => format!("cut `{title}` — p to paste (move)"),
});
}
fn tree_paste(&mut self) {
use crate::store::NodeKind;
let Some((src, mode)) = self.tree_clipboard else {
self.status_message = Some("nothing to paste (y yank / x cut first)".to_string());
return;
};
let Some(cursor) = self.facts_tree.selected() else { return };
let parent = match self.hierarchy.get(cursor).map(|n| n.kind) {
Some(NodeKind::Book | NodeKind::Chapter | NodeKind::Subchapter) => Some(cursor),
_ => self.hierarchy.get(cursor).and_then(|n| n.parent_id),
};
let result: Result<Uuid, String> = match mode {
ClipMode::Copy => self
.store
.copy_paragraph_to_parent(&self.cfg, &self.hierarchy, src, parent)
.map_err(|e| e.to_string()),
ClipMode::Move => self
.store
.move_node_to_parent(&self.cfg, &self.hierarchy, src, parent)
.map(|_| src)
.map_err(|e| e.to_string()),
};
match result {
Ok(landed) => {
if mode == ClipMode::Move {
self.tree_clipboard = None;
}
self.reload_hierarchy();
let _ = self.facts_tree.reveal(&self.hierarchy, landed);
self.status_message =
Some(if mode == ClipMode::Copy { "pasted a copy".into() } else { "moved".into() });
}
Err(e) => self.status_message = Some(format!("paste failed: {e}")),
}
}
fn tree_move(&mut self, up: bool) {
use crate::store::NodeKind;
let Some(id) = self.facts_tree.selected() else { return };
let Some(node) = self.hierarchy.get(id) else { return };
let parent = node.parent_id;
let siblings: Vec<Uuid> = self
.hierarchy
.children_of(parent)
.into_iter()
.filter(|n| !matches!(n.kind, NodeKind::Image | NodeKind::Script))
.map(|n| n.id)
.collect();
let Some(pos) = siblings.iter().position(|s| *s == id) else { return };
let other = if up {
if pos == 0 {
return;
}
siblings[pos - 1]
} else {
if pos + 1 >= siblings.len() {
return;
}
siblings[pos + 1]
};
match self.store.swap_siblings(&self.hierarchy, id, other) {
Ok(()) => {
self.reload_hierarchy();
let _ = self.facts_tree.reveal(&self.hierarchy, id);
self.status_message = Some(if up { "moved up".into() } else { "moved down".into() });
}
Err(e) => self.status_message = Some(format!("move failed: {e}")),
}
}
}
fn build_prompt_history(thread: &ResearchThread) -> Vec<String> {
let mut out = Vec::new();
for turn in thread.turns.iter().rev() {
let entry = match turn.kind {
TurnKind::Query => turn.prompt.clone(),
TurnKind::FactInsertion | TurnKind::NoteInsertion => turn.command.clone(),
};
if let Some(e) = entry {
if !e.trim().is_empty() && !out.contains(&e) {
out.push(e);
}
}
}
out
}
pub(crate) fn import_cli(layout: &ProjectLayout, cfg: &Config, store: &Store, path: &str) -> Result<()> {
use super::imports;
let p = std::path::Path::new(path);
let text = imports::read_source(p)?;
let chunks = imports::chunk_text(&text, cfg.research.import_chunk_chars.max(200));
if chunks.is_empty() {
anyhow::bail!("no text extracted from {path}");
}
let name = imports::source_name(p);
let abs = std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
let now = chrono::Utc::now().to_rfc3339();
let mut doc_ids = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
let meta = serde_json::json!({
"kind": imports::SOURCE_KIND, "source": abs, "name": name,
"thread": "", "chunk": i, "imported_at": now,
});
let id = store.raw().add_document(meta, chunk.as_bytes()).map_err(|e| anyhow::anyhow!("{e}"))?;
doc_ids.push(id.to_string());
}
let mut imports_store = imports::Imports::load(layout);
let chunks_n = doc_ids.len();
imports_store.sources.insert(
name.clone(),
imports::ImportedSource {
name: name.clone(),
path: abs,
doc_ids,
thread: String::new(),
imported_at: now,
chunks: chunks_n,
},
);
imports_store.save(layout)?;
println!("imported `{name}` — {chunks_n} chunk(s) as a research source");
Ok(())
}
pub(crate) fn list_threads_cli(layout: &ProjectLayout, format: Option<&str>) -> Result<()> {
let summaries = thread::list_threads(layout);
if format == Some("json") {
println!("{}", serde_json::to_string_pretty(&summaries)?);
return Ok(());
}
println!("{:<15} {:<13} {:>5} {}", "Thread", "Last active", "Turns", "Cost");
if summaries.is_empty() {
println!("(no research threads yet)");
}
for s in &summaries {
let date = s.last_active.get(0..10).unwrap_or(&s.last_active);
println!("{:<15} {:<13} {:>5} ${:.2}", s.name, date, s.turns, s.cost);
}
Ok(())
}
pub(crate) fn export_thread_cli(
layout: &ProjectLayout,
name: &str,
format: Option<&str>,
out: Option<&str>,
) -> Result<()> {
let slug = thread::thread_slug(name);
let thread = ResearchThread::load(layout, &slug)
.ok_or_else(|| anyhow::anyhow!("thread `{name}` not found"))?;
let body = match format {
Some("json") => serde_json::to_string_pretty(&thread)?,
_ => export_markdown(&thread),
};
match out {
Some(path) => std::fs::write(path, body)?,
None => println!("{body}"),
}
Ok(())
}
fn export_markdown(thread: &ResearchThread) -> String {
use std::fmt::Write;
let mut s = String::new();
let _ = writeln!(s, "# Research thread: {}\n", thread.display_name);
let _ = writeln!(s, "_Created {} · last active {}_\n", thread.created_at, thread.last_active);
for turn in &thread.turns {
match turn.kind {
super::thread::TurnKind::Query => {
if let Some(p) = &turn.prompt {
let _ = writeln!(s, "## {p}\n");
}
if let Some(r) = &turn.response {
let _ = writeln!(s, "{r}\n");
}
}
super::thread::TurnKind::FactInsertion | super::thread::TurnKind::NoteInsertion => {
let book = turn.target_book.as_deref().unwrap_or("Facts");
let title = turn.extracted_title.as_deref().unwrap_or("");
let text = turn.extracted_text.as_deref().unwrap_or("");
let path = turn.insertion_path.as_deref().unwrap_or("");
let _ = writeln!(s, "> **[{book}] {title}** → `{path}`\n>\n> {text}\n");
}
}
}
s
}