1#![forbid(unsafe_code)]
2
3mod chat_ui;
4
5use anyhow::{Context, Result};
6use clap::{CommandFactory, Parser, Subcommand};
7use eli_core::config::{self, ApprovalMode, AutoMode, ConfigFile, DisplayMode, Paths, RunMode};
8use eli_core::contract::{self, StepStatus};
9use eli_core::diff::engine::{DiffEngine, DiffResult};
10use eli_core::diff::engine::UndoManager;
11use eli_core::executor::command_runner::{CommandResult, CommandRunner};
12use eli_core::orchestrator::{compact_memory_now, maybe_compact_memory, run_subagents, SubagentResult};
13use eli_core::persistence::{EventKind, SessionEvent, SessionStore};
14use eli_core::types::{ChatMessage, ChatRequest, ProviderKind};
15use eli_core::LlmAdapter;
16use futures::StreamExt;
17use console::Term as ConsoleTerm;
18use crossterm::cursor;
19use crossterm::event::{self as ct_event, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind, KeyModifiers as CtKeyModifiers};
20use crossterm::queue;
21use crossterm::style::{Attribute, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor};
22use crossterm::terminal::{self};
23use rustyline::completion::{Completer, Pair};
24use rustyline::error::ReadlineError;
25use rustyline::highlight::Highlighter;
26use rustyline::history::DefaultHistory;
27use rustyline::hint::Hinter;
28use rustyline::{
29 Cmd, CompletionType, ConditionalEventHandler, Config, Context as RustyContext, Editor, Event,
30 EventHandler, Helper, KeyCode, KeyEvent, Modifiers,
31};
32use rustyline::validate::Validator;
33use std::io::Write;
34use std::sync::{Arc, Mutex};
35use std::path::{Path, PathBuf};
36use ratatui::buffer::Buffer;
37use ratatui::layout::Rect;
38use ratatui::prelude::Widget;
39use ratatui::style::{Color, Modifier, Style};
40use ratatui::text::{Line, Span};
41use ratatui::widgets::{Block, Borders, Clear, Paragraph};
42use serde::Serialize;
43use termimad::MadSkin;
44use textwrap::{wrap, Options as WrapOptions};
45use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
46use std::time::{Duration, Instant};
47use tracing::{info, warn};
48
49#[derive(Clone, Debug)]
50struct ResearchArtifact {
51 rel_path: String,
52 title: String,
53 status: String,
54 created_utc: String,
55 answer_hint: Option<String>,
56}
57
58#[derive(Clone, Serialize)]
59struct ToolInfoArgCount {
60 min: usize,
61 max: usize,
62}
63
64#[derive(Clone, Serialize)]
65struct ToolInfoArg {
66 name: String,
67 long: Option<String>,
68 short: Option<String>,
69 help: Option<String>,
70 required: bool,
71 value_type: String,
72 num_args: Option<ToolInfoArgCount>,
73 value_names: Option<Vec<String>>,
74 possible_values: Option<Vec<String>>,
75 default_values: Option<Vec<String>>,
76}
77
78#[derive(Clone, Serialize)]
79struct ToolInfoSubcommand {
80 name: String,
81 about: Option<String>,
82}
83
84#[derive(Clone, Serialize)]
85struct ToolInfoResponse {
86 command: String,
87 about: Option<String>,
88 args: Vec<ToolInfoArg>,
89 subcommands: Vec<ToolInfoSubcommand>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 error: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 available_subcommands: Option<Vec<ToolInfoSubcommand>>,
94}
95
96struct SessionState {
98 display_mode: DisplayMode,
99 auto_mode: AutoMode,
100 total_work_time: Duration,
101 step_count: u32,
102 prompt_queue: Vec<String>,
103 input_buffer: String,
104 cursor_pos: usize,
105 prompt_history: Vec<String>,
106 history_cursor: Option<usize>,
107 recent_research: Vec<ResearchArtifact>,
108 total_usage: eli_core::types::Usage,
109 last_usage: Option<eli_core::types::Usage>,
110}
111
112const FOOTER_SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
113
114struct FooterUi {
115 height: u16,
116 active: bool,
117 term_width: usize,
118 term_height: usize,
119}
120
121impl FooterUi {
122 fn enable() -> Self {
123 terminal::enable_raw_mode().ok();
124 let mut out = std::io::stdout();
125 queue!(out, cursor::Hide).ok();
126 out.flush().ok();
127 let (w, h) = terminal_size();
128 let mut this = Self {
129 height: 3,
130 active: true,
131 term_width: w,
132 term_height: h,
133 };
134 this.clear_footer_rows(&mut out);
136 this.apply_scroll_region();
137 this
138 }
139
140 fn disable(&mut self) {
141 if !self.active {
142 return;
143 }
144 self.active = false;
145 let mut out = std::io::stdout();
147 self.clear_footer_rows(&mut out);
148 self.reset_scroll_region();
149 queue!(out, cursor::Show).ok();
150 out.flush().ok();
151 terminal::disable_raw_mode().ok();
152 }
153
154 fn clear_footer_rows(&self, out: &mut std::io::Stdout) {
155 write!(out, "\x1b[r").ok();
157 let footer_top = self.term_height.saturating_sub(self.height as usize);
158 for row in footer_top..self.term_height {
159 write!(out, "\x1b[{};1H\x1b[2K", row + 1).ok();
160 }
161 out.flush().ok();
162 }
163
164 fn apply_scroll_region(&mut self) {
165 let bottom = self
166 .term_height
167 .saturating_sub(self.height as usize)
168 .max(1);
169 let mut out = std::io::stdout();
170 write!(out, "\x1b[1;{}r", bottom).ok();
172 write!(out, "\x1b[{};1H", bottom).ok();
174 out.flush().ok();
175 }
176
177 fn reset_scroll_region(&self) {
178 let mut out = std::io::stdout();
179 write!(out, "\x1b[r").ok();
180 out.flush().ok();
181 }
182
183 fn render(&mut self, title: &str, input: &str, cursor_pos: usize) {
184 let (width, height) = terminal_size();
185 if width != self.term_width || height != self.term_height {
186 let mut out = std::io::stdout();
187
188 write!(out, "\x1b[r").ok();
190
191 let old_footer_top = self.term_height.saturating_sub(self.height as usize);
193 let new_footer_top = height.saturating_sub(self.height as usize);
194 let clear_from = old_footer_top.min(new_footer_top);
195
196 write!(out, "\x1b[{};1H", clear_from + 1).ok(); write!(out, "\x1b[J").ok(); out.flush().ok();
200
201 self.term_width = width;
203 self.term_height = height;
204 self.apply_scroll_region();
205 }
206 let footer_top = height.saturating_sub(self.height as usize);
207 let rect = Rect::new(0, 0, width as u16, self.height);
208 let mut buf = Buffer::empty(rect);
209
210 let inner_width = width.saturating_sub(4).max(1); let prompt = "› ";
213 let cursor_pos = cursor_pos.min(input.len());
214 let (before_cursor, after_cursor) = input.split_at(cursor_pos);
215
216 let cursor_char = after_cursor.chars().next().unwrap_or(' ');
218 let rest = if after_cursor.len() > cursor_char.len_utf8() {
219 &after_cursor[cursor_char.len_utf8()..]
220 } else {
221 ""
222 };
223
224 let line = Line::from(vec![
226 Span::styled(prompt, Style::default().fg(Color::Cyan)),
227 Span::styled(before_cursor, Style::default().fg(Color::White)),
228 Span::styled(
229 cursor_char.to_string(),
230 Style::default().fg(Color::Black).bg(Color::White),
231 ),
232 Span::styled(rest, Style::default().fg(Color::White)),
233 ]);
234
235 Clear.render(rect, &mut buf);
236 let block = Block::default()
237 .borders(Borders::ALL)
238 .border_style(Style::new().fg(Color::Cyan))
239 .title_style(Style::new().fg(Color::Cyan))
240 .title(title);
241 let paragraph = Paragraph::new(line).block(block);
242 paragraph.render(rect, &mut buf);
243
244 let mut out = std::io::stdout();
245 flush_buffer(&mut out, &buf, rect, footer_top as u16);
246 let scroll_y = footer_top.saturating_sub(1);
247 queue!(out, cursor::MoveTo(0, scroll_y as u16)).ok();
248 out.flush().ok();
249 }
250}
251
252impl Drop for FooterUi {
253 fn drop(&mut self) {
254 self.disable();
255 }
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq)]
259enum PromptMode {
260 Ask,
261 Plan,
262 Auto,
263}
264
265fn prompt_mode(state: &SessionState, chat: &eli_core::config::ChatConfig) -> PromptMode {
266 let _ = (state, chat);
267 PromptMode::Auto
268}
269
270fn print_history_block(lines: Vec<String>) {
271 use std::io::Write;
272
273 let out = format_indented_block(&lines);
274 if !out.is_empty() {
275 print!("{}", out);
276 std::io::stdout().flush().ok();
277 }
278}
279
280fn print_history_line(line: String) {
281 print_history_block(vec![line]);
282}
283
284fn apply_prompt_mode(_mode: PromptMode, state: &mut SessionState, chat: &mut eli_core::config::ChatConfig) {
285 state.auto_mode = AutoMode::Autonomous;
286 chat.approvals = ApprovalMode::Auto;
287 chat.approvals_commands = None;
288 chat.approvals_diffs = None;
289 chat.auto_mode = state.auto_mode;
290}
291
292fn cycle_prompt_mode(state: &mut SessionState, chat: &mut eli_core::config::ChatConfig) {
293 apply_prompt_mode(PromptMode::Auto, state, chat);
294}
295
296
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298enum AgentProfile {
299 Coding,
300 Research,
301}
302
303impl SessionState {
304 fn new(cfg: &eli_core::config::ChatConfig) -> Self {
305 Self {
306 display_mode: cfg.display_mode,
307 auto_mode: cfg.auto_mode,
308 total_work_time: Duration::ZERO,
309 step_count: 0,
310 prompt_queue: Vec::new(),
311 input_buffer: String::new(),
312 cursor_pos: 0,
313 prompt_history: Vec::new(),
314 history_cursor: None,
315 recent_research: Vec::new(),
316 total_usage: eli_core::types::Usage::default(),
317 last_usage: None,
318 }
319 }
320
321 fn queue_prompt(&mut self, prompt: String) {
322 self.prompt_queue.push(prompt);
323 }
324
325 fn next_prompt(&mut self) -> Option<String> {
326 if self.prompt_queue.is_empty() {
327 None
328 } else {
329 Some(self.prompt_queue.remove(0))
330 }
331 }
332
333 fn queue_len(&self) -> usize {
334 self.prompt_queue.len()
335 }
336
337 fn load_recent_research(&mut self, project_root: &Path, max_items: usize) {
338 self.recent_research = discover_recent_research(project_root, max_items);
339 }
340
341 fn record_research_report(&mut self, artifact: ResearchArtifact, max_items: usize) {
342 self.recent_research
344 .retain(|a| a.rel_path != artifact.rel_path);
345 self.recent_research.insert(0, artifact);
346 if self.recent_research.len() > max_items {
347 self.recent_research.truncate(max_items);
348 }
349 }
350
351 fn recent_research_context(&self, max_items: usize, max_chars: usize) -> Option<String> {
352 if self.recent_research.is_empty() || max_items == 0 || max_chars == 0 {
353 return None;
354 }
355
356 let mut out = String::new();
357 out.push_str("RECENT_RESEARCH (open with `cat` if needed):\n");
358 for (idx, a) in self.recent_research.iter().take(max_items).enumerate() {
359 let status = if a.status.trim().is_empty() {
360 "unknown"
361 } else {
362 a.status.trim()
363 };
364 out.push_str(&format!(
365 "{}. {} — {} ({}, {})\n",
366 idx + 1,
367 a.rel_path,
368 truncate(&a.title, 120),
369 status,
370 a.created_utc
371 ));
372 if idx == 0 {
373 if let Some(hint) = &a.answer_hint {
374 let hint = hint.trim();
375 if !hint.is_empty() {
376 out.push_str(&format!(" last_answer: {}\n", truncate(hint, 220)));
377 }
378 }
379 }
380 }
381
382 Some(truncate(&out, max_chars))
383 }
384}
385
386#[derive(Clone, Copy)]
387struct SlashCommand {
388 name: &'static str,
389 desc: &'static str,
390}
391
392const SLASH_COMMANDS: &[SlashCommand] = &[
393 SlashCommand {
394 name: "/help",
395 desc: "show help",
396 },
397 SlashCommand {
398 name: "/?",
399 desc: "alias for /help",
400 },
401 SlashCommand {
402 name: "/$",
403 desc: "show cost/usage stats",
404 },
405 SlashCommand {
406 name: "/brain",
407 desc: "full output (tools, history, details)",
408 },
409 SlashCommand {
410 name: "/debug",
411 desc: "debug output (raw request/response + tool output + observation)",
412 },
413 SlashCommand {
414 name: "/standard",
415 desc: "brief output (recent stream, summary)",
416 },
417 SlashCommand {
418 name: "/brief",
419 desc: "alias for /standard",
420 },
421 SlashCommand {
422 name: "/mode",
423 desc: "set exec mode (read/work)",
424 },
425 SlashCommand {
426 name: "/read",
427 desc: "set exec mode to read",
428 },
429 SlashCommand {
430 name: "/work",
431 desc: "set exec mode to work",
432 },
433 SlashCommand {
434 name: "/bot",
435 desc: "work mode; cmds auto, diffs ask",
436 },
437 SlashCommand {
438 name: "/yolo",
439 desc: "work mode; auto approvals",
440 },
441 SlashCommand {
442 name: "/model",
443 desc: "set or show model for this session",
444 },
445 SlashCommand {
446 name: "/models",
447 desc: "show current model and usage",
448 },
449 SlashCommand {
450 name: "/key",
451 desc: "set API key for current provider",
452 },
453 SlashCommand {
454 name: "/queue",
455 desc: "show queued prompts",
456 },
457 SlashCommand {
458 name: "/q",
459 desc: "alias for /queue",
460 },
461 SlashCommand {
462 name: "/clear-queue",
463 desc: "clear queued prompts",
464 },
465 SlashCommand {
466 name: "/cq",
467 desc: "alias for /clear-queue",
468 },
469 SlashCommand {
470 name: "/status",
471 desc: "show current mode/stats",
472 },
473 SlashCommand {
474 name: "/s",
475 desc: "alias for /status",
476 },
477 SlashCommand {
478 name: "/compact",
479 desc: "summarize older context (reduce tokens)",
480 },
481 SlashCommand {
482 name: "/reset",
483 desc: "clear conversation",
484 },
485 SlashCommand {
486 name: "/new",
487 desc: "alias for /reset",
488 },
489 SlashCommand {
490 name: "/tip",
491 desc: "toggle tips (standard mode)",
492 },
493 SlashCommand {
494 name: "/undo",
495 desc: "undo last edit",
496 },
497 SlashCommand {
498 name: "/exit",
499 desc: "quit",
500 },
501 SlashCommand {
502 name: "/quit",
503 desc: "alias for /exit",
504 },
505];
506
507#[derive(Clone, Default)]
508struct SlashHelper {
509 last_input_tokens: std::sync::Arc<std::sync::atomic::AtomicUsize>,
510}
511
512impl Helper for SlashHelper {}
513impl Highlighter for SlashHelper {}
514impl Validator for SlashHelper {}
515
516impl Hinter for SlashHelper {
517 type Hint = String;
518
519 fn hint(&self, line: &str, pos: usize, _ctx: &RustyContext<'_>) -> Option<Self::Hint> {
520 if pos < line.len() {
521 return None;
522 }
523
524 if is_slash_command_context(line, pos) {
525 let prefix = &line[..pos];
526 if let Some(cmd) = SLASH_COMMANDS.iter().find(|c| c.name.starts_with(prefix)) {
527 return Some(cmd.name[prefix.len()..].to_string());
528 }
529 }
530
531 let tokens = self.last_input_tokens.load(std::sync::atomic::Ordering::Relaxed);
533 if tokens > 0 {
534 return Some(format!(" {}Input: ~{} tokens{}", style::DARK_GRAY, tokens, style::RESET));
536 }
537
538 None
539 }
540}
541
542impl Completer for SlashHelper {
543 type Candidate = Pair;
544
545 fn complete(
546 &self,
547 line: &str,
548 pos: usize,
549 _ctx: &RustyContext<'_>,
550 ) -> rustyline::Result<(usize, Vec<Pair>)> {
551 let before = &line[..pos];
552 if !is_slash_command_context(line, pos) {
553 return Ok((pos, Vec::new()));
554 }
555 let mut out = Vec::new();
556 for cmd in SLASH_COMMANDS {
557 if cmd.name.starts_with(before) {
558 out.push(Pair {
559 display: format!("{:<14} {}", cmd.name, cmd.desc),
560 replacement: cmd.name.to_string(),
561 });
562 }
563 }
564 Ok((0, out))
565 }
566}
567
568#[derive(Clone)]
569struct SlashMenu {
570 state: Arc<Mutex<SlashMenuState>>,
571}
572
573#[derive(Default)]
574struct SlashMenuState {
575 shown: bool,
576}
577
578impl SlashMenu {
579 fn new() -> Self {
580 Self {
581 state: Arc::new(Mutex::new(SlashMenuState::default())),
582 }
583 }
584
585 fn reset(&self) {
586 if let Ok(mut state) = self.state.lock() {
587 state.shown = false;
588 }
589 }
590
591 fn show(&self) {
592 let mut show = false;
593 if let Ok(mut state) = self.state.lock() {
594 if !state.shown {
595 state.shown = true;
596 show = true;
597 }
598 }
599 if show {
600 let lines = slash_menu_lines();
601 let out = format_box_string(&lines);
602 if !out.is_empty() {
603 println!("{out}");
604 }
605 }
606 }
607}
608
609#[derive(Clone, Copy)]
610enum SlashNav {
611 Next,
612 Prev,
613}
614
615#[derive(Clone)]
616struct SlashMenuHandler {
617 menu: SlashMenu,
618}
619
620impl SlashMenuHandler {
621 fn new(menu: SlashMenu) -> Self {
622 Self { menu }
623 }
624}
625
626impl ConditionalEventHandler for SlashMenuHandler {
627 fn handle(
628 &self,
629 _evt: &Event,
630 _n: usize,
631 _positive: bool,
632 ctx: &rustyline::EventContext,
633 ) -> Option<Cmd> {
634 if ctx.pos() == 0 && ctx.line().trim().is_empty() {
635 self.menu.show();
636 }
637 None
638 }
639}
640
641#[derive(Clone)]
642struct SlashNavHandler {
643 menu: SlashMenu,
644 dir: SlashNav,
645}
646
647impl SlashNavHandler {
648 fn new(menu: SlashMenu, dir: SlashNav) -> Self {
649 Self { menu, dir }
650 }
651}
652
653impl ConditionalEventHandler for SlashNavHandler {
654 fn handle(
655 &self,
656 _evt: &Event,
657 _n: usize,
658 _positive: bool,
659 ctx: &rustyline::EventContext,
660 ) -> Option<Cmd> {
661 if !is_slash_command_context(ctx.line(), ctx.pos()) {
662 return None;
663 }
664 self.menu.show();
665 match self.dir {
666 SlashNav::Next => Some(Cmd::Complete),
667 SlashNav::Prev => Some(Cmd::CompleteBackward),
668 }
669 }
670}
671
672#[derive(Parser, Debug)]
673#[command(name = "eli", version, about = "Eli: a terminal CLI coding agent")]
674struct Cli {
675 #[command(subcommand)]
676 cmd: Option<Command>,
677
678 #[arg(long, global = true)]
680 provider: Option<String>,
681
682 #[arg(long, global = true)]
684 model: Option<String>,
685}
686
687#[derive(Subcommand, Debug)]
688enum Command {
689 Setup,
691
692 Init,
694
695 Config {
697 #[arg(long)]
699 set: Option<String>,
700
701 #[arg(long)]
703 value: Option<String>,
704 },
705
706 #[command(hide = true)]
708 ToolInfo {
709 #[arg(value_name = "PATH", num_args = 0..)]
711 path: Vec<String>,
712 },
713
714 Chat,
716
717 Debug,
719
720 Raw,
722
723 Research {
725 query: String,
727 },
728
729 Tui,
731
732 Finance {
734 #[command(subcommand)]
735 cmd: FinanceCommand,
736 },
737
738 Web {
740 #[command(subcommand)]
741 cmd: WebCommand,
742 },
743}
744
745#[derive(Subcommand, Debug)]
746enum FinanceCommand {
747 Timeseries(FinanceTimeseriesArgs),
749 Snapshot(FinanceSnapshotArgs),
751 Fundamentals(FinanceFundamentalsArgs),
753 Search(FinanceSearchArgs),
755 Filings(FinanceFilingsArgs),
757 Sec(FinanceFilingsArgs),
759 News(FinanceNewsArgs),
761 Macro(FinanceMacroArgs),
763 Prices(FinancePricesArgs),
765 Odds(FinanceOddsArgs),
767 Options(FinanceOptionsArgs),
769}
770
771#[derive(Subcommand, Debug)]
772enum WebCommand {
773 Crawl(WebCrawlArgs),
775 Search(WebSearchArgs),
777 Read(WebReadArgs),
779 Extract(WebExtractArgs),
781}
782
783#[derive(clap::Args, Debug)]
784struct WebCrawlArgs {
785 #[arg(long)]
787 url: String,
788
789 #[arg(long, default_value = "50")]
791 max_pages: usize,
792
793 #[arg(long, default_value = "true")]
795 respect_robots: bool,
796
797 #[arg(long, default_value = "false")]
799 subdomains: bool,
800
801 #[arg(long)]
803 out: Option<PathBuf>,
804}
805
806#[derive(clap::Args, Debug)]
807struct WebSearchArgs {
808 #[arg(long)]
810 query: String,
811
812 #[arg(long)]
814 out: Option<PathBuf>,
815}
816
817#[derive(clap::Args, Debug)]
818struct WebReadArgs {
819 #[arg(long)]
821 url: String,
822
823 #[arg(long)]
825 out: Option<PathBuf>,
826}
827
828#[derive(clap::Args, Debug)]
829struct WebExtractArgs {
830 #[arg(long)]
832 url: Option<String>,
833
834 #[arg(long)]
836 file: Option<PathBuf>,
837
838 #[arg(long)]
840 text: Option<String>,
841
842 #[arg(long, default_value = "10")]
844 bullets: usize,
845
846 #[arg(long)]
848 focus: Option<String>,
849
850 #[arg(long)]
852 out: Option<PathBuf>,
853}
854
855#[derive(clap::Args, Debug)]
856pub struct FinanceMacroArgs {
857 #[arg(long, default_value = "1y")]
859 pub range: String,
860 #[arg(long, default_value = "json")]
862 pub format: String,
863 #[arg(short, long)]
865 pub out: Option<PathBuf>,
866}
867
868
869#[derive(clap::Args, Debug)]
870struct FinanceNewsArgs {
871 #[arg(long, visible_alias = "tickers")]
873 ticker: String,
874
875 #[arg(long)]
877 date: String,
878
879 #[arg(long)]
881 out: Option<PathBuf>,
882}
883
884#[derive(clap::Args, Debug)]
885struct FinanceSnapshotArgs {
886 #[arg(long, visible_alias = "ticker", value_delimiter = ',')]
888 tickers: Vec<String>,
889
890 #[arg(long)]
892 tickers_file: Option<PathBuf>,
893
894 #[arg(long, default_value = "yahoo")]
896 provider: String,
897
898 #[arg(long, default_value = "json")]
900 format: String,
901
902 #[arg(long)]
904 out: Option<PathBuf>,
905}
906
907#[derive(clap::Args, Debug)]
908struct FinanceFundamentalsArgs {
909 #[arg(long, visible_alias = "tickers")]
911 ticker: String,
912
913 #[arg(long, default_value = "json")]
915 format: String,
916
917 #[arg(long)]
919 out: Option<PathBuf>,
920}
921
922#[derive(clap::Args, Debug)]
923struct FinanceSearchArgs {
924 #[arg(long)]
926 query: String,
927
928 #[arg(long, default_value = "json")]
930 format: String,
931
932 #[arg(long)]
934 out: Option<PathBuf>,
935}
936
937#[derive(clap::Args, Debug)]
938struct FinancePricesArgs {
939 #[arg(long)]
941 query: Option<String>,
942
943 #[arg(long)]
945 asset_type: Option<String>,
946
947 #[arg(long, value_delimiter = ',')]
949 ids: Vec<String>,
950
951 #[arg(long, default_value = "json")]
953 format: String,
954
955 #[arg(long)]
957 out: Option<PathBuf>,
958}
959
960#[derive(clap::Args, Debug)]
961struct FinanceOddsArgs {
962 #[arg(long)]
964 provider: Option<String>,
965 #[arg(long)]
967 series: Option<String>,
968
969 #[arg(long)]
971 event: Option<String>,
972
973 #[arg(long)]
975 market: Option<String>,
976
977 #[arg(long)]
979 status: Option<String>,
980
981 #[arg(long)]
983 limit: Option<usize>,
984
985 #[arg(long)]
987 cursor: Option<String>,
988
989 #[arg(long)]
991 max_pages: Option<usize>,
992
993 #[arg(long)]
995 list_series: bool,
996
997 #[arg(long)]
999 list_events: bool,
1000
1001 #[arg(long)]
1003 list_markets: bool,
1004
1005 #[arg(long)]
1007 list_tags: bool,
1008
1009 #[arg(long)]
1011 category: Option<String>,
1012
1013 #[arg(long)]
1015 search: Option<String>,
1016
1017 #[arg(long)]
1019 orderbook: bool,
1020
1021 #[arg(long)]
1023 depth: Option<usize>,
1024
1025 #[arg(long, default_value = "json")]
1027 format: String,
1028
1029 #[arg(long)]
1031 out: Option<PathBuf>,
1032}
1033
1034#[derive(clap::Args, Debug)]
1035struct FinanceOptionsArgs {
1036 #[arg(long, visible_alias = "tickers")]
1038 ticker: String,
1039
1040 #[arg(long)]
1042 expiry: Option<String>,
1043
1044 #[arg(long = "type", value_name = "calls|puts|both")]
1046 option_type: Option<String>,
1047
1048 #[arg(long = "near-money")]
1050 near_money: Option<f64>,
1051
1052 #[arg(long)]
1054 summary: bool,
1055
1056 #[arg(long)]
1058 expirations: bool,
1059
1060 #[arg(long, default_value = "json")]
1062 format: String,
1063
1064 #[arg(long)]
1066 out: Option<PathBuf>,
1067}
1068
1069#[derive(clap::Args, Debug)]
1070struct FinanceFilingsArgs {
1071 #[arg(long, visible_alias = "tickers")]
1073 ticker: String,
1074
1075 #[arg(long, value_delimiter = ',')]
1077 forms: Vec<String>,
1078
1079 #[arg(long, default_value_t = 5)]
1081 limit: usize,
1082
1083 #[arg(long)]
1085 include_text: bool,
1086
1087 #[arg(long)]
1089 max_chars: Option<usize>,
1090
1091 #[arg(long)]
1093 cache_dir: Option<PathBuf>,
1094
1095 #[arg(long, default_value = "json")]
1097 format: String,
1098
1099 #[arg(long)]
1101 out: Option<PathBuf>,
1102}
1103
1104#[derive(clap::Args, Debug)]
1105struct FinanceTimeseriesArgs {
1106 #[arg(long, visible_alias = "ticker", value_delimiter = ',')]
1108 tickers: Vec<String>,
1109
1110 #[arg(long)]
1112 tickers_file: Option<PathBuf>,
1113
1114 #[arg(long, default_value = "1y")]
1116 range: String,
1117
1118 #[arg(long, default_value = "1d")]
1120 granularity: String,
1121
1122 #[arg(long)]
1124 as_of: Option<String>,
1125
1126 #[arg(long, default_value = "yahoo")]
1128 provider: String,
1129
1130 #[arg(long)]
1132 max_points_per_ticker: Option<usize>,
1133
1134 #[arg(long)]
1136 cache_dir: Option<PathBuf>,
1137
1138 #[arg(long, default_value = "json")]
1140 format: String,
1141
1142 #[arg(long)]
1144 out: Option<PathBuf>,
1145}
1146
1147pub async fn run() -> Result<()> {
1148 tracing_subscriber::fmt()
1149 .with_env_filter(
1150 std::env::var("RUST_LOG").unwrap_or_else(|_| "eli=info,eli_cli=info".to_string()),
1151 )
1152 .init();
1153
1154 let cli = Cli::try_parse()?;
1155
1156 match cli.cmd {
1157 None => cmd_chat(cli.provider, cli.model, None).await,
1158 Some(Command::Setup) => cmd_setup().await,
1159 Some(Command::Init) => cmd_init().await,
1160 Some(Command::Config { set, value }) => cmd_config(set, value).await,
1161 Some(Command::ToolInfo { path }) => cmd_tool_info(path),
1162 Some(Command::Chat) => cmd_chat(cli.provider, cli.model, None).await,
1163 Some(Command::Debug) => cmd_chat(cli.provider, cli.model, Some(DisplayMode::Debug)).await,
1164 Some(Command::Raw) => cmd_chat(cli.provider, cli.model, Some(DisplayMode::Raw)).await,
1165 Some(Command::Research { query }) => cmd_research(query, cli.provider, cli.model).await,
1166 Some(Command::Tui) => cmd_tui().await,
1167 Some(Command::Finance { cmd }) => cmd_finance(cmd).await,
1168 Some(Command::Web { cmd }) => cmd_web(cmd).await,
1169 }
1170}
1171
1172async fn cmd_research(query: String, provider: Option<String>, model: Option<String>) -> Result<()> {
1173 let paths = Paths::discover().context("discover paths")?;
1174 let mut cfg = config::load_or_create(&paths).context("load/create config")?;
1175 apply_overrides(&mut cfg, provider, model)?;
1176
1177 cfg.chat.mode = RunMode::Read;
1179 cfg.chat.approvals = ApprovalMode::Auto;
1180 cfg.chat.auto = true;
1181
1182 let adapter = eli_adapters::build_from_chat_config(&cfg.chat).context("build adapter")?;
1183 let adapter: Arc<dyn LlmAdapter> = Arc::from(adapter);
1184
1185 let cwd = std::env::current_dir().context("get cwd")?;
1186 let project_root = cfg
1187 .chat
1188 .resolved_project_root(&cwd)
1189 .map_err(|e| anyhow::anyhow!(e))
1190 .context("resolve project root")?;
1191
1192 ensure_eli_research_brain(&project_root).context("ensure eli_research/ELI.md")?;
1193
1194 let diff_engine = DiffEngine::new(project_root.clone()).context("init diff engine")?;
1195 let command_runner = CommandRunner::new(
1196 cfg.chat.timeout_secs,
1197 cfg.chat.max_cmds,
1198 cfg.chat.parallel_commands,
1199 project_root.clone(),
1200 );
1201
1202 let store = SessionStore::new(&paths);
1203 let session_id = uuid::Uuid::new_v4().to_string();
1204
1205 let instincts_dir = project_root.join("instincts");
1207 if !instincts_dir.exists() {
1208 let _ = std::fs::create_dir_all(&instincts_dir);
1209 }
1210
1211 info!(session_id = %session_id, provider = %cfg.chat.provider, model = %cfg.chat.model, "starting research");
1212
1213 let mut memory = eli_core::memory::Memory::new(cfg.chat.mem_steps);
1214 memory.set_system(eli_core::contract::system_prompt());
1215
1216 if instincts_dir.exists() {
1218 if let Ok(entries) = std::fs::read_dir(&instincts_dir) {
1219 for entry in entries.flatten() {
1220 if let Ok(content) = std::fs::read_to_string(entry.path()) {
1221 let filename = entry.file_name().to_string_lossy().to_string();
1222 memory.push(ChatMessage::system(format!(
1223 "INSTINCT ({filename}):\n{content}"
1224 )));
1225 }
1226 }
1227 }
1228 }
1229 let mut undo_stack: Vec<Vec<DiffResult>> = Vec::new();
1230 let mut state = SessionState::new(&cfg.chat);
1231 state.load_recent_research(&project_root, 12);
1232
1233 print_banner(&cfg.chat, &project_root, &state);
1234
1235 run_agent_steps(
1236 &cfg.chat,
1237 adapter.clone(),
1238 &diff_engine,
1239 &command_runner,
1240 &store,
1241 &paths.data_dir,
1242 &session_id,
1243 &project_root,
1244 &mut memory,
1245 &mut undo_stack,
1246 &mut state,
1247 AgentProfile::Research,
1248 query,
1249 Vec::new(),
1250 )
1251 .await?;
1252
1253 print_cost_stats(&state, &cfg.chat);
1254
1255 Ok(())
1256}
1257
1258async fn cmd_finance(cmd: FinanceCommand) -> Result<()> {
1259 match cmd {
1260 FinanceCommand::Timeseries(args) => cmd_finance_timeseries(args).await,
1261 FinanceCommand::Snapshot(args) => cmd_finance_snapshot(args).await,
1262 FinanceCommand::Fundamentals(args) => cmd_finance_fundamentals(args).await,
1263 FinanceCommand::Search(args) => cmd_finance_search(args).await,
1264 FinanceCommand::Filings(args) | FinanceCommand::Sec(args) => cmd_finance_filings(args).await,
1265 FinanceCommand::News(args) => cmd_finance_news(args).await,
1266 FinanceCommand::Macro(args) => cmd_finance_macro(args).await,
1267 FinanceCommand::Prices(args) => cmd_finance_prices(args).await,
1268 FinanceCommand::Odds(args) => cmd_finance_odds(args).await,
1269 FinanceCommand::Options(args) => cmd_finance_options(args).await,
1270 }
1271}
1272
1273async fn cmd_web(cmd: WebCommand) -> Result<()> {
1274 match cmd {
1275 WebCommand::Crawl(args) => cmd_web_crawl(args).await,
1276 WebCommand::Search(args) => cmd_web_search(args).await,
1277 WebCommand::Read(args) => cmd_web_read(args).await,
1278 WebCommand::Extract(args) => cmd_web_extract(args).await,
1279 }
1280}
1281
1282async fn cmd_web_crawl(args: WebCrawlArgs) -> Result<()> {
1283 let req = eli_core::web::CrawlRequest {
1284 url: args.url,
1285 max_pages: Some(args.max_pages),
1286 respect_robots: args.respect_robots,
1287 include_subdomains: args.subdomains,
1288 };
1289
1290 let resp = eli_core::web::crawl_website(req)
1291 .await
1292 .map_err(|e| anyhow::anyhow!("{}", e))
1293 .context("crawl website")?;
1294
1295 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1296
1297 if let Some(out_path) = args.out {
1298 let out_path = redirect_finance_output(out_path);
1299 if let Some(parent) = out_path.parent() {
1300 std::fs::create_dir_all(parent).ok();
1301 }
1302 std::fs::write(&out_path, &json).context("write --out")?;
1303 println!(
1304 "{{\"ok\":true,\"path\":{}}}",
1305 serde_json::to_string(&out_path.display().to_string())
1306 .unwrap_or_else(|_| "\"\"".to_string()),
1307 );
1308 return Ok(());
1309 }
1310
1311 println!("{json}");
1312 Ok(())
1313}
1314
1315async fn cmd_web_search(args: WebSearchArgs) -> Result<()> {
1316 let hits = eli_core::web::providers::general::search_general(&args.query)
1317 .await
1318 .map_err(|e| anyhow::anyhow!("{}", e))
1319 .context("web search")?;
1320
1321 let resp = eli_core::web::WebSearchResponse { hits };
1322 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1323
1324 if let Some(out_path) = args.out {
1325 let out_path = redirect_finance_output(out_path);
1326 if let Some(parent) = out_path.parent() {
1327 std::fs::create_dir_all(parent).ok();
1328 }
1329 std::fs::write(&out_path, &json).context("write --out")?;
1330 println!(
1331 "{{\"ok\":true,\"path\":{}}}",
1332 serde_json::to_string(&out_path.display().to_string())
1333 .unwrap_or_else(|_| "\"\"".to_string()),
1334 );
1335 return Ok(());
1336 }
1337
1338 println!("{json}");
1339 Ok(())
1340}
1341
1342async fn cmd_web_read(args: WebReadArgs) -> Result<()> {
1343 let article = eli_core::web::providers::read::read_url(&args.url)
1344 .await
1345 .map_err(|e| anyhow::anyhow!("{}", e))
1346 .context("read url")?;
1347
1348 let json = serde_json::to_string_pretty(&article).context("serialize response")?;
1349
1350 if let Some(out_path) = args.out {
1351 let out_path = redirect_finance_output(out_path);
1352 if let Some(parent) = out_path.parent() {
1353 std::fs::create_dir_all(parent).ok();
1354 }
1355 std::fs::write(&out_path, &json).context("write --out")?;
1356 println!(
1357 "{{\"ok\":true,\"path\":{}}}",
1358 serde_json::to_string(&out_path.display().to_string())
1359 .unwrap_or_else(|_| "\"\"".to_string()),
1360 );
1361 return Ok(());
1362 }
1363
1364 println!("{json}");
1365 Ok(())
1366}
1367
1368async fn cmd_web_extract(args: WebExtractArgs) -> Result<()> {
1369 let resp = if let Some(url) = args.url {
1370 eli_core::extraction::extract_from_url(&url, args.bullets, args.focus)
1371 .await
1372 .map_err(|e| anyhow::anyhow!("{}", e))
1373 .context("extract from url")?
1374 } else if let Some(file) = args.file {
1375 eli_core::extraction::extract_from_file(&file, args.bullets, args.focus)
1376 .map_err(|e| anyhow::anyhow!("{}", e))
1377 .context("extract from file")?
1378 } else if let Some(text) = args.text {
1379 let req = eli_core::extraction::ExtractRequest {
1380 content: text,
1381 source: "inline".to_string(),
1382 bullets: args.bullets,
1383 focus: args.focus,
1384 };
1385 eli_core::extraction::extract_facts(req)
1386 .map_err(|e| anyhow::anyhow!("{}", e))
1387 .context("extract from text")?
1388 } else {
1389 anyhow::bail!("must provide --url, --file, or --text");
1390 };
1391
1392 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1393
1394 if let Some(out_path) = args.out {
1395 let out_path = redirect_finance_output(out_path);
1396 if let Some(parent) = out_path.parent() {
1397 std::fs::create_dir_all(parent).ok();
1398 }
1399 std::fs::write(&out_path, &json).context("write --out")?;
1400 println!(
1401 "{{\"ok\":true,\"path\":{}}}",
1402 serde_json::to_string(&out_path.display().to_string())
1403 .unwrap_or_else(|_| "\"\"".to_string()),
1404 );
1405 return Ok(());
1406 }
1407
1408 println!("{json}");
1409 Ok(())
1410}
1411
1412fn redirect_finance_output(path: std::path::PathBuf) -> std::path::PathBuf {
1414 if path.parent().map(|p| p == std::path::Path::new("") || p == std::path::Path::new(".")).unwrap_or(true) {
1416 if let Some(filename) = path.file_name() {
1417 let target = std::path::Path::new("eli_research/data").join(filename);
1418 if let Some(parent) = target.parent() {
1420 std::fs::create_dir_all(parent).ok();
1421 }
1422 return target;
1423 }
1424 }
1425 path
1426}
1427
1428async fn cmd_finance_macro(args: FinanceMacroArgs) -> Result<()> {
1429 if args.format.trim().to_ascii_lowercase() != "json" {
1430 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1431 }
1432
1433 let range = if args.range.is_empty() {
1434 None
1435 } else {
1436 match eli_core::finance::Span::parse(&args.range) {
1437 Ok(s) => Some(s),
1438 Err(e) => anyhow::bail!("invalid --range '{}': {}", args.range, e),
1439 }
1440 };
1441
1442 let req = eli_core::finance::MacroRequest { range };
1443 let resp = eli_core::finance::fetch_macro(req)
1444 .await
1445 .map_err(|e| anyhow::anyhow!(e))
1446 .context("fetch macro")?;
1447
1448 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1449
1450 if let Some(out_path) = args.out {
1451 let out_path = redirect_finance_output(out_path);
1452 std::fs::write(&out_path, &json).context("write output file")?;
1453 }
1454
1455 println!("{json}");
1456 Ok(())
1457}
1458
1459async fn cmd_finance_prices(args: FinancePricesArgs) -> Result<()> {
1460 if args.format.trim().to_ascii_lowercase() != "json" {
1461 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1462 }
1463
1464 let req = eli_core::finance::PricesRequest {
1465 query: args.query,
1466 asset_type: args.asset_type,
1467 ids: args.ids,
1468 };
1469
1470 let resp = eli_core::finance::fetch_prices(req)
1471 .await
1472 .map_err(|e| anyhow::anyhow!(e))
1473 .context("fetch prices")?;
1474
1475 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1476
1477 if let Some(out_path) = args.out {
1478 let out_path = redirect_finance_output(out_path);
1479 if let Some(parent) = out_path.parent() {
1480 std::fs::create_dir_all(parent).ok();
1481 }
1482 std::fs::write(&out_path, json).context("write --out")?;
1483 println!(
1484 "{{\"ok\":true,\"path\":{}}}",
1485 serde_json::to_string(&out_path.display().to_string())
1486 .unwrap_or_else(|_| "\"\"".to_string()),
1487 );
1488 return Ok(());
1489 }
1490
1491 println!("{json}");
1492 Ok(())
1493}
1494
1495async fn cmd_finance_odds(args: FinanceOddsArgs) -> Result<()> {
1496 if args.format.trim().to_ascii_lowercase() != "json" {
1497 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1498 }
1499
1500 let provider = args.provider.as_ref().map(|s| s.trim().to_ascii_lowercase());
1501 let provider = match provider {
1502 None => None,
1503 Some(p) if p.is_empty() => None,
1504 Some(p) => match p.as_str() {
1505 "kalshi" | "polymarket" | "auto" => Some(p),
1506 other => anyhow::bail!(
1507 "unsupported --provider '{other}' (supported: kalshi, polymarket, auto)"
1508 ),
1509 },
1510 };
1511
1512 let req = eli_core::finance::OddsRequest {
1513 provider,
1514 disable_kalshi: false,
1515 series_ticker: args.series,
1516 event_ticker: args.event,
1517 market_ticker: args.market,
1518 status: args.status,
1519 limit: args.limit,
1520 cursor: args.cursor,
1521 max_pages: args.max_pages,
1522 include_orderbook: args.orderbook,
1523 orderbook_depth: args.depth,
1524 list_series: args.list_series,
1525 list_events: args.list_events,
1526 list_markets: args.list_markets,
1527 list_tags: args.list_tags,
1528 category: args.category,
1529 search: args.search,
1530 };
1531
1532 let resp = eli_core::finance::fetch_odds(req)
1533 .await
1534 .map_err(|e| anyhow::anyhow!(e))
1535 .context("fetch odds")?;
1536
1537 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1538
1539 if let Some(out_path) = args.out {
1540 let out_path = redirect_finance_output(out_path);
1541 if let Some(parent) = out_path.parent() {
1542 std::fs::create_dir_all(parent).ok();
1543 }
1544 std::fs::write(&out_path, json).context("write --out")?;
1545 println!(
1546 "{{\"ok\":true,\"path\":{}}}",
1547 serde_json::to_string(&out_path.display().to_string())
1548 .unwrap_or_else(|_| "\"\"".to_string()),
1549 );
1550 return Ok(());
1551 }
1552
1553 println!("{json}");
1554 Ok(())
1555}
1556
1557async fn cmd_finance_options(args: FinanceOptionsArgs) -> Result<()> {
1558 if args.format.trim().to_ascii_lowercase() != "json" {
1559 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1560 }
1561
1562 if args.summary && args.expirations {
1563 anyhow::bail!("use only one of --summary or --expirations");
1564 }
1565
1566 let option_type = match args.option_type.as_deref().map(|s| s.trim().to_ascii_lowercase()) {
1567 None => None,
1568 Some(t) if t == "both" || t.is_empty() => None,
1569 Some(t) if t == "calls" || t == "puts" => Some(t),
1570 Some(other) => anyhow::bail!("invalid --type '{other}' (expected calls|puts|both)"),
1571 };
1572
1573 let req = eli_core::finance::OptionsRequest {
1574 ticker: args.ticker,
1575 expiry: args.expiry,
1576 option_type,
1577 near_money_pct: args.near_money,
1578 summary_only: args.summary,
1579 list_expirations: args.expirations,
1580 multi_expiry: false,
1581 num_expiries: None,
1582 };
1583
1584 let resp = eli_core::finance::fetch_options(req)
1585 .await
1586 .map_err(|e| anyhow::anyhow!(e))
1587 .context("fetch options")?;
1588
1589 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1590
1591 if let Some(out_path) = args.out {
1592 let out_path = redirect_finance_output(out_path);
1593 if let Some(parent) = out_path.parent() {
1594 std::fs::create_dir_all(parent).ok();
1595 }
1596 std::fs::write(&out_path, json).context("write --out")?;
1597 println!(
1598 "{{\"ok\":true,\"path\":{}}}",
1599 serde_json::to_string(&out_path.display().to_string())
1600 .unwrap_or_else(|_| "\"\"".to_string()),
1601 );
1602 return Ok(());
1603 }
1604
1605 println!("{json}");
1606 Ok(())
1607}
1608
1609async fn cmd_finance_news(args: FinanceNewsArgs) -> Result<()> {
1610 let req = eli_core::finance::NewsRequest {
1611 ticker: args.ticker,
1612 date: args.date,
1613 };
1614
1615 let resp = eli_core::finance::fetch_news(req).await
1616 .map_err(|e| anyhow::anyhow!(e))?;
1617
1618 let json = serde_json::to_string_pretty(&resp)?;
1619
1620 if let Some(out_path) = args.out {
1621 let out_path = redirect_finance_output(out_path);
1622 if let Some(parent) = out_path.parent() {
1623 std::fs::create_dir_all(parent).ok();
1624 }
1625 std::fs::write(&out_path, &json).context("write --out")?;
1626 println!(
1627 "{{\"ok\":true,\"path\":{}}}",
1628 serde_json::to_string(&out_path.display().to_string())
1629 .unwrap_or_else(|_| "\"\"".to_string()),
1630 );
1631 return Ok(());
1632 }
1633
1634 println!("{}", json);
1635 Ok(())
1636}
1637
1638async fn cmd_finance_snapshot(args: FinanceSnapshotArgs) -> Result<()> {
1639 if args.format.trim().to_ascii_lowercase() != "json" {
1640 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1641 }
1642
1643 let mut tickers = args.tickers;
1644 if let Some(path) = args.tickers_file {
1645 let raw = std::fs::read_to_string(&path).context("read tickers_file")?;
1646 for line in raw.lines() {
1647 let t = line.trim();
1648 if t.is_empty() || t.starts_with('#') {
1649 continue;
1650 }
1651 tickers.push(t.to_string());
1652 }
1653 }
1654
1655 let provider = match args.provider.trim().to_ascii_lowercase().as_str() {
1656 "mock" => eli_core::finance::ProviderKind::Mock,
1657 "yahoo" => eli_core::finance::ProviderKind::Yahoo,
1658 other => anyhow::bail!("unsupported --provider '{other}' (supported: mock, yahoo)"),
1659 };
1660
1661 let req = eli_core::finance::SnapshotRequest { tickers, provider };
1662 let resp = eli_core::finance::fetch_snapshot(req)
1663 .await
1664 .map_err(|e| anyhow::anyhow!(e))
1665 .context("fetch snapshot")?;
1666
1667 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1668
1669 if let Some(out_path) = args.out {
1670 let out_path = redirect_finance_output(out_path);
1671 if let Some(parent) = out_path.parent() {
1672 std::fs::create_dir_all(parent).ok();
1673 }
1674 std::fs::write(&out_path, json).context("write --out")?;
1675 println!(
1676 "{{\"ok\":true,\"path\":{}}}",
1677 serde_json::to_string(&out_path.display().to_string())
1678 .unwrap_or_else(|_| "\"\"".to_string()),
1679 );
1680 return Ok(());
1681 }
1682
1683 println!("{json}");
1684 Ok(())
1685}
1686
1687async fn cmd_finance_fundamentals(args: FinanceFundamentalsArgs) -> Result<()> {
1688 if args.format.trim().to_ascii_lowercase() != "json" {
1689 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1690 }
1691
1692 let req = eli_core::finance::FundamentalsRequest { ticker: args.ticker };
1693 let resp = eli_core::finance::fetch_fundamentals(req)
1694 .await
1695 .map_err(|e| anyhow::anyhow!(e))
1696 .context("fetch fundamentals")?;
1697
1698 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1699
1700 if let Some(out_path) = args.out {
1701 let out_path = redirect_finance_output(out_path);
1702 if let Some(parent) = out_path.parent() {
1703 std::fs::create_dir_all(parent).ok();
1704 }
1705 std::fs::write(&out_path, json).context("write --out")?;
1706 println!(
1707 "{{\"ok\":true,\"path\":{}}}",
1708 serde_json::to_string(&out_path.display().to_string())
1709 .unwrap_or_else(|_| "\"\"".to_string()),
1710 );
1711 return Ok(());
1712 }
1713
1714 println!("{json}");
1715 Ok(())
1716}
1717
1718async fn cmd_finance_search(args: FinanceSearchArgs) -> Result<()> {
1719 if args.format.trim().to_ascii_lowercase() != "json" {
1720 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1721 }
1722
1723 let req = eli_core::finance::SearchRequest { query: args.query };
1724 let resp = eli_core::finance::fetch_search(req)
1725 .await
1726 .map_err(|e| anyhow::anyhow!(e))
1727 .context("fetch search")?;
1728
1729 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1730
1731 if let Some(out_path) = args.out {
1732 let out_path = redirect_finance_output(out_path);
1733 if let Some(parent) = out_path.parent() {
1734 std::fs::create_dir_all(parent).ok();
1735 }
1736 std::fs::write(&out_path, json).context("write --out")?;
1737 println!(
1738 "{{\"ok\":true,\"path\":{}}}",
1739 serde_json::to_string(&out_path.display().to_string())
1740 .unwrap_or_else(|_| "\"\"".to_string()),
1741 );
1742 return Ok(());
1743 }
1744
1745 println!("{json}");
1746 Ok(())
1747}
1748
1749async fn cmd_finance_filings(args: FinanceFilingsArgs) -> Result<()> {
1750 if args.format.trim().to_ascii_lowercase() != "json" {
1751 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1752 }
1753
1754 let cache_dir = if let Some(path) = args.cache_dir {
1755 path
1756 } else {
1757 let paths = Paths::discover().context("discover paths")?;
1758 paths.ensure_dirs().context("ensure dirs")?;
1759 paths.cache_dir
1760 };
1761
1762 let paths = Paths::discover().ok();
1763 let config = if let Some(p) = paths {
1764 config::load_or_default(&p).ok()
1765 } else {
1766 None
1767 };
1768
1769 let user_agent = config.and_then(|c| c.chat.sec_user_agent);
1770
1771 let req = eli_core::finance::FilingsRequest {
1772 ticker: args.ticker,
1773 forms: args.forms,
1774 limit: Some(args.limit),
1775 include_text: args.include_text,
1776 max_chars: args.max_chars,
1777 user_agent,
1778 };
1779
1780 let resp = eli_core::finance::fetch_filings(req, &cache_dir)
1781 .await
1782 .map_err(|e| anyhow::anyhow!(e))
1783 .context("fetch filings")?;
1784
1785 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1786
1787 if let Some(out_path) = args.out {
1788 let out_path = redirect_finance_output(out_path);
1789 if let Some(parent) = out_path.parent() {
1790 std::fs::create_dir_all(parent).ok();
1791 }
1792 std::fs::write(&out_path, json).context("write --out")?;
1793 println!(
1794 "{{\"ok\":true,\"path\":{}}}",
1795 serde_json::to_string(&out_path.display().to_string())
1796 .unwrap_or_else(|_| "\"\"".to_string()),
1797 );
1798 return Ok(());
1799 }
1800
1801 println!("{json}");
1802 Ok(())
1803}
1804
1805async fn cmd_finance_timeseries(args: FinanceTimeseriesArgs) -> Result<()> {
1806 if args.format.trim().to_ascii_lowercase() != "json" {
1807 anyhow::bail!("unsupported --format (only 'json' is implemented)");
1808 }
1809
1810 let mut tickers = args.tickers;
1811 if let Some(path) = args.tickers_file {
1812 let raw = std::fs::read_to_string(&path).context("read tickers_file")?;
1813 for line in raw.lines() {
1814 let t = line.trim();
1815 if t.is_empty() || t.starts_with('#') {
1816 continue;
1817 }
1818 tickers.push(t.to_string());
1819 }
1820 }
1821
1822 let range = eli_core::finance::Span::parse(&args.range)
1823 .map_err(|e| anyhow::anyhow!(e))
1824 .context("parse --range")?;
1825 let granularity = eli_core::finance::Span::parse(&args.granularity)
1826 .map_err(|e| anyhow::anyhow!(e))
1827 .context("parse --granularity")?;
1828
1829 let as_of = match args.as_of {
1830 Some(raw) => Some(
1831 eli_core::finance::parse_as_of(&raw)
1832 .map_err(|e| anyhow::anyhow!(e))
1833 .context("parse --as-of")?,
1834 ),
1835 None => None,
1836 };
1837
1838 let provider = match args.provider.trim().to_ascii_lowercase().as_str() {
1839 "mock" => eli_core::finance::ProviderKind::Mock,
1840 "yahoo" => eli_core::finance::ProviderKind::Yahoo,
1841 "fred" => eli_core::finance::ProviderKind::Fred,
1842 other => anyhow::bail!(
1843 "unsupported --provider '{other}' (supported: mock, yahoo, fred)"
1844 ),
1845 };
1846
1847 let cache_dir = if let Some(path) = args.cache_dir {
1848 path
1849 } else {
1850 let paths = Paths::discover().context("discover paths")?;
1851 paths.ensure_dirs().context("ensure dirs")?;
1852 paths.cache_dir
1853 };
1854
1855 let req = eli_core::finance::TimeseriesRequest {
1856 tickers,
1857 range,
1858 granularity,
1859 as_of,
1860 provider,
1861 max_points_per_ticker: args.max_points_per_ticker,
1862 };
1863
1864 let resp = eli_core::finance::fetch_timeseries(req, &cache_dir)
1865 .await
1866 .map_err(|e| anyhow::anyhow!(e))
1867 .context("fetch timeseries")?;
1868
1869 let json = serde_json::to_string_pretty(&resp).context("serialize response")?;
1870
1871 if let Some(out_path) = args.out {
1872 let out_path = redirect_finance_output(out_path);
1873 if let Some(parent) = out_path.parent() {
1874 std::fs::create_dir_all(parent).ok();
1875 }
1876 std::fs::write(&out_path, json).context("write --out")?;
1877 println!(
1878 "{{\"ok\":true,\"path\":{},\"cache\":{}}}",
1879 serde_json::to_string(&out_path.display().to_string()).unwrap_or_else(|_| "\"\"".to_string()),
1880 serde_json::to_string(&resp.cache).unwrap_or_else(|_| "null".to_string())
1881 );
1882 return Ok(());
1883 }
1884
1885 println!("{json}");
1886 Ok(())
1887}
1888
1889async fn cmd_setup() -> Result<()> {
1890 use std::io::Write;
1891 let paths = Paths::discover().context("discover paths")?;
1892 paths.ensure_dirs().context("ensure config dirs")?;
1893 let mut cfg = config::load_or_default(&paths).context("load config")?;
1894
1895 println!("=== Eli Setup ===\n");
1896
1897 println!("Select provider:");
1899 println!(" 1) anthropic - Claude models (recommended)");
1900 println!(" 2) openai - GPT models");
1901 println!(" 3) openrouter - Multiple providers via OpenRouter");
1902 println!(" 4) ollama - Local models (no API key needed)");
1903 print!("\nChoice [1-4]: ");
1904 std::io::stdout().flush().ok();
1905
1906 let mut input = String::new();
1907 std::io::stdin().read_line(&mut input).context("read provider choice")?;
1908 let provider = match input.trim() {
1909 "1" | "anthropic" => ProviderKind::Anthropic,
1910 "2" | "openai" => ProviderKind::OpenAI,
1911 "3" | "openrouter" => ProviderKind::OpenRouter,
1912 "4" | "ollama" => ProviderKind::Ollama,
1913 _ => {
1914 println!("Invalid choice, defaulting to anthropic");
1915 ProviderKind::Anthropic
1916 }
1917 };
1918 cfg.chat.provider = provider;
1919
1920 let default_model = match provider {
1922 ProviderKind::Anthropic => "claude-sonnet-4-20250514",
1923 ProviderKind::OpenAI => "gpt-4o",
1924 ProviderKind::OpenRouter => "mistralai/devstral-2512:free",
1925 ProviderKind::Ollama => "llama3.2",
1926 ProviderKind::Mock => "mock",
1927 };
1928
1929 print!("\nModel [{}]: ", default_model);
1930 std::io::stdout().flush().ok();
1931 input.clear();
1932 std::io::stdin().read_line(&mut input).context("read model")?;
1933 let model = input.trim();
1934 cfg.chat.model = if model.is_empty() {
1935 default_model.to_string()
1936 } else {
1937 model.to_string()
1938 };
1939
1940 if provider != ProviderKind::Ollama {
1942 print!("\nAPI Key: ");
1943 std::io::stdout().flush().ok();
1944 input.clear();
1945 std::io::stdin().read_line(&mut input).context("read api key")?;
1946 let key = input.trim().to_string();
1947
1948 if !key.is_empty() {
1949 match provider {
1950 ProviderKind::Anthropic => cfg.chat.anthropic_api_key = Some(key),
1951 ProviderKind::OpenAI => cfg.chat.openai_api_key = Some(key),
1952 ProviderKind::OpenRouter => cfg.chat.openrouter_api_key = Some(key),
1953 _ => {} }
1955 }
1956 }
1957
1958 config::save(&paths, &cfg).context("save config")?;
1960
1961 println!("\n=== Configuration saved! ===");
1962 println!("Config file: {}", paths.config_file().display());
1963 println!("Provider: {}", cfg.chat.provider);
1964 println!("Model: {}", cfg.chat.model);
1965 println!("\nJust run 'eli' to start chatting!");
1966
1967 Ok(())
1968}
1969
1970async fn cmd_init() -> Result<()> {
1971 let paths = Paths::discover().context("discover paths")?;
1972 let cfg = config::load_or_create(&paths).context("load/create config")?;
1973 println!("Config file: {}", paths.config_file().display());
1974 println!("{}", toml::to_string_pretty(&cfg).context("serialize config")?);
1975 Ok(())
1976}
1977
1978async fn cmd_config(set: Option<String>, value: Option<String>) -> Result<()> {
1979 let paths = Paths::discover().context("discover paths")?;
1980
1981 if let Some(key) = set {
1983 let val = value.unwrap_or_default();
1984 let mut cfg = config::load_or_create(&paths).context("load config")?;
1985
1986 match key.to_lowercase().as_str() {
1987 "provider" => {
1988 cfg.chat.provider = val
1989 .parse::<ProviderKind>()
1990 .map_err(|e| anyhow::anyhow!(e))
1991 .context("invalid provider")?;
1992 println!("Set provider = {}", cfg.chat.provider);
1993 }
1994 "model" => {
1995 cfg.chat.model = val.clone();
1996 println!("Set model = {}", val);
1997 }
1998 "mem_steps" | "memory" | "mem" => {
1999 cfg.chat.mem_steps = val.parse::<usize>().context("mem_steps must be a number")?;
2000 println!("Set mem_steps = {}", cfg.chat.mem_steps);
2001 }
2002 "key" | "api_key" | "apikey" => {
2003 match cfg.chat.provider {
2004 ProviderKind::Anthropic => cfg.chat.anthropic_api_key = Some(val.clone()),
2005 ProviderKind::OpenAI => cfg.chat.openai_api_key = Some(val.clone()),
2006 ProviderKind::OpenRouter => cfg.chat.openrouter_api_key = Some(val.clone()),
2007 _ => {} }
2009 println!("Set API key for {}", cfg.chat.provider);
2010 }
2011 "anthropic_key" | "anthropic_api_key" => {
2012 cfg.chat.anthropic_api_key = Some(val.clone());
2013 println!("Set anthropic_api_key");
2014 }
2015 "openai_key" | "openai_api_key" => {
2016 cfg.chat.openai_api_key = Some(val.clone());
2017 println!("Set openai_api_key");
2018 }
2019 "openrouter_key" | "openrouter_api_key" => {
2020 cfg.chat.openrouter_api_key = Some(val.clone());
2021 println!("Set openrouter_api_key");
2022 }
2023 "sec_user_agent" | "sec_ua" => {
2024 cfg.chat.sec_user_agent = Some(val.clone());
2025 println!("Set sec_user_agent = {}", val);
2026 }
2027 "compact" => {
2028 cfg.chat.compact = parse_bool(&val)?;
2029 println!("Set compact = {}", cfg.chat.compact);
2030 }
2031 "compact_trigger" => {
2032 cfg.chat.compact_trigger = Some(val.parse::<usize>().context("compact_trigger must be a number")?);
2033 println!("Set compact_trigger = {}", cfg.chat.compact_trigger.unwrap_or(0));
2034 }
2035 "compact_keep" => {
2036 cfg.chat.compact_keep = Some(val.parse::<usize>().context("compact_keep must be a number")?);
2037 println!("Set compact_keep = {}", cfg.chat.compact_keep.unwrap_or(0));
2038 }
2039 "summary_model" => {
2040 cfg.chat.summary_model = if val.trim().is_empty() { None } else { Some(val.clone()) };
2041 println!("Set summary_model = {}", cfg.chat.summary_model.clone().unwrap_or_else(|| "none".to_string()));
2042 }
2043 "parallel_commands" | "parallel_cmds" => {
2044 cfg.chat.parallel_commands = val.parse::<u32>().context("parallel_commands must be a number")?;
2045 println!("Set parallel_commands = {}", cfg.chat.parallel_commands);
2046 }
2047 "parallel_subagents" | "parallel_agents" => {
2048 cfg.chat.parallel_subagents = val.parse::<u32>().context("parallel_subagents must be a number")?;
2049 println!("Set parallel_subagents = {}", cfg.chat.parallel_subagents);
2050 }
2051 "scrollback_max_lines" | "scrollback" => {
2052 cfg.chat.scrollback_max_lines = val.parse::<usize>().context("scrollback_max_lines must be a number")?;
2053 println!("Set scrollback_max_lines = {}", cfg.chat.scrollback_max_lines);
2054 }
2055 other => {
2056 anyhow::bail!("Unknown config key: {}. Valid keys: provider, model, mem_steps, key, anthropic_key, openai_key, openrouter_key, sec_user_agent, compact, compact_trigger, compact_keep, summary_model, parallel_commands, parallel_subagents, scrollback_max_lines", other);
2057 }
2058 }
2059
2060 config::save(&paths, &cfg).context("save config")?;
2061 return Ok(())
2062 }
2063
2064 let cfg = config::load_or_default(&paths).context("load config")?;
2066 println!("Config file: {}", paths.config_file().display());
2067 println!("{}", toml::to_string_pretty(&cfg).context("serialize config")?);
2068 Ok(())
2069}
2070
2071fn build_tool_info(path: &[String]) -> ToolInfoResponse {
2072 use clap::{ArgAction, ValueHint};
2073
2074 let mut cmd = Cli::command();
2075 let mut full_path = vec![cmd.get_name().to_string()];
2076 let mut missing: Option<String> = None;
2077
2078 for seg in path {
2079 let next = cmd
2080 .get_subcommands()
2081 .find(|c| c.get_name() == seg.as_str())
2082 .cloned();
2083 if let Some(sub) = next {
2084 cmd = sub;
2085 full_path.push(seg.clone());
2086 } else {
2087 missing = Some(seg.clone());
2088 break;
2089 }
2090 }
2091
2092 let args: Vec<ToolInfoArg> = cmd
2093 .get_arguments()
2094 .map(|arg| {
2095 let num_args = arg.get_num_args().map(|range| ToolInfoArgCount {
2096 min: range.min_values(),
2097 max: range.max_values(),
2098 });
2099
2100 let value_names = arg
2101 .get_value_names()
2102 .map(|names| names.iter().map(|n| n.to_string()).collect::<Vec<_>>());
2103
2104 let possible_values = arg.get_value_parser().possible_values().map(|vals| {
2105 vals.map(|v| v.get_name().to_string()).collect::<Vec<_>>()
2106 });
2107
2108 let default_values = arg
2109 .get_default_values()
2110 .iter()
2111 .map(|v| v.to_string_lossy().to_string())
2112 .collect::<Vec<_>>();
2113 let default_values = if default_values.is_empty() {
2114 None
2115 } else {
2116 Some(default_values)
2117 };
2118
2119 let action = arg.get_action();
2120 let mut value_type = if matches!(*action, ArgAction::SetTrue | ArgAction::SetFalse) {
2121 "bool".to_string()
2122 } else if matches!(*action, ArgAction::Count) {
2123 "count".to_string()
2124 } else if possible_values.is_some() {
2125 "enum".to_string()
2126 } else {
2127 "string".to_string()
2128 };
2129
2130 let type_id = arg.get_value_parser().type_id();
2131 if value_type == "string" {
2132 if type_id == std::any::TypeId::of::<bool>() {
2133 value_type = "bool".to_string();
2134 } else if type_id == std::any::TypeId::of::<std::path::PathBuf>() {
2135 value_type = "path".to_string();
2136 } else if type_id == std::any::TypeId::of::<usize>()
2137 || type_id == std::any::TypeId::of::<u64>()
2138 || type_id == std::any::TypeId::of::<u32>()
2139 || type_id == std::any::TypeId::of::<u16>()
2140 || type_id == std::any::TypeId::of::<u8>()
2141 || type_id == std::any::TypeId::of::<i64>()
2142 || type_id == std::any::TypeId::of::<i32>()
2143 || type_id == std::any::TypeId::of::<i16>()
2144 || type_id == std::any::TypeId::of::<i8>()
2145 || type_id == std::any::TypeId::of::<f64>()
2146 || type_id == std::any::TypeId::of::<f32>()
2147 {
2148 value_type = "number".to_string();
2149 }
2150 }
2151
2152 if let ValueHint::FilePath
2153 | ValueHint::DirPath
2154 | ValueHint::ExecutablePath = arg.get_value_hint()
2155 {
2156 value_type = "path".to_string();
2157 }
2158
2159 ToolInfoArg {
2160 name: arg.get_id().to_string(),
2161 long: arg.get_long().map(|s| s.to_string()),
2162 short: arg.get_short().map(|c| c.to_string()),
2163 help: arg.get_help().map(|s| s.to_string()),
2164 required: arg.is_required_set(),
2165 value_type,
2166 num_args,
2167 value_names,
2168 possible_values,
2169 default_values,
2170 }
2171 })
2172 .collect();
2173
2174 let subcommands: Vec<ToolInfoSubcommand> = cmd
2175 .get_subcommands()
2176 .map(|sub| ToolInfoSubcommand {
2177 name: sub.get_name().to_string(),
2178 about: sub.get_about().map(|s| s.to_string()),
2179 })
2180 .collect();
2181
2182 let (error, available_subcommands) = if let Some(missing) = missing {
2183 (
2184 Some(format!("unknown subcommand '{missing}'")),
2185 Some(subcommands.clone()),
2186 )
2187 } else {
2188 (None, None)
2189 };
2190
2191 ToolInfoResponse {
2192 command: full_path.join(" "),
2193 about: cmd.get_about().map(|s| s.to_string()),
2194 args,
2195 subcommands,
2196 error,
2197 available_subcommands,
2198 }
2199}
2200
2201fn cmd_tool_info(path: Vec<String>) -> Result<()> {
2202 let resp = build_tool_info(&path);
2203
2204 let json = serde_json::to_string_pretty(&resp).context("serialize tool-info")?;
2205 println!("{json}");
2206 Ok(())
2207}
2208
2209async fn run_chat_tui(
2211 cfg: &mut ConfigFile,
2212 adapter: Arc<dyn LlmAdapter>,
2213 diff_engine: &DiffEngine,
2214 command_runner: &CommandRunner,
2215 store: &SessionStore,
2216 paths: &Paths,
2217 session_id: &str,
2218 project_root: &Path,
2219 memory: &mut eli_core::memory::Memory,
2220 undo_stack: &mut Vec<Vec<DiffResult>>,
2221) -> Result<()> {
2222 use chat_ui::{ChatTerminal, ChatUi, PromptMode as TuiPromptMode};
2223 use crossterm::event::{Event, KeyEventKind};
2224
2225 let mut ui = ChatUi::new();
2226 ui.prompt_mode = TuiPromptMode::Auto;
2227 ui.scrollback_max_lines = cfg.chat.scrollback_max_lines;
2228 let mut terminal = ChatTerminal::new().context("create TUI terminal")?;
2229
2230 cfg.chat.approvals = ApprovalMode::Auto;
2232 cfg.chat.auto_mode = AutoMode::Autonomous;
2233 let apply_tui_mode = |_mode: TuiPromptMode, cfg: &mut ConfigFile| {
2234 cfg.chat.approvals = ApprovalMode::Auto;
2235 cfg.chat.auto_mode = AutoMode::Autonomous;
2236 };
2237
2238 let task_start = Instant::now();
2239
2240 loop {
2241 ui.tick_spinner();
2243 ui.elapsed_secs = task_start.elapsed().as_secs();
2244
2245 terminal.draw(&mut ui)?;
2247
2248 if let Some(event) = terminal.poll_event(Duration::from_millis(50))? {
2250 match event {
2251 Event::Paste(text) => {
2252 ui.handle_paste(&text);
2253 continue;
2254 }
2255 Event::Key(key) => {
2256 if key.kind == KeyEventKind::Press {
2257 if let Some(input) = ui.handle_key(key.code, key.modifiers) {
2258 let trimmed = input.trim();
2259
2260 if trimmed == "/exit" || trimmed == "/quit" {
2262 break;
2263 }
2264 if trimmed == "/help" {
2265 ui.add_message(
2266 "System",
2267 "Commands: /exit, /help, /model, /compact, /reset, /copy, /status\n/copy [scope] [> file] - Copy session: all, last, user, tools, N, -data\nKeys: Esc interrupt, ↑↓ history, PgUp/PgDn scroll",
2268 );
2269 continue;
2270 }
2271 if trimmed == "/model" || trimmed.starts_with("/model ") {
2272 let model = trimmed.strip_prefix("/model").unwrap_or("").trim();
2273 if model.is_empty() {
2274 ui.add_message("System", &format!("model: {}", cfg.chat.model));
2275 } else {
2276 cfg.chat.model = model.to_string();
2277 ui.add_message("System", &format!("(model: {})", cfg.chat.model));
2278 }
2279 continue;
2280 }
2281 if trimmed == "/models" {
2282 ui.add_message("System", &format!("model: {}\nset with: /model <name>", cfg.chat.model));
2283 continue;
2284 }
2285 if trimmed == "/compact" {
2286 match compact_memory_now(adapter.clone(), &cfg.chat, memory).await {
2287 Ok(Some(compaction)) => {
2288 let note = format!(
2289 "memory_compaction: dropped {} messages\n{}",
2290 compaction.dropped,
2291 compaction.summary
2292 );
2293 let brain_entry = format!(
2294 "\n### {} (session {})\n{}\n",
2295 chrono::Utc::now().to_rfc3339(),
2296 session_id,
2297 note
2298 );
2299 if let Err(e) = append_eli_brain(project_root, &brain_entry) {
2300 ui.add_message("System", &format!("(compacted, but failed to write brain: {e})"));
2301 } else {
2302 ui.add_message("System", &format!("memory: compacted ({} msgs)", compaction.dropped));
2303 }
2304 store
2305 .append(
2306 session_id,
2307 &SessionEvent {
2308 ts: chrono::Utc::now(),
2309 kind: EventKind::Note { content: note },
2310 },
2311 )
2312 .await
2313 .ok();
2314 }
2315 Ok(None) => ui.add_message("System", "(nothing to compact)"),
2316 Err(e) => ui.add_message("Error", &format!("compact failed: {e}")),
2317 }
2318 continue;
2319 }
2320 if trimmed == "/tip" {
2321 ui.show_tips = !ui.show_tips;
2322 ui.add_message(
2323 "System",
2324 if ui.show_tips { "Tips shown." } else { "Tips hidden." },
2325 );
2326 continue;
2327 }
2328 if trimmed == "/brain" || trimmed == "/debug" || trimmed == "/raw" {
2329 ui.add_message("System", &format!(
2331 "Can't switch to {} mode mid-session. Exit and run: eli chat --display {}",
2332 trimmed.trim_start_matches('/'),
2333 trimmed.trim_start_matches('/')
2334 ));
2335 continue;
2336 }
2337 if trimmed == "/standard" {
2338 ui.add_message("System", "Already in standard (TUI) mode.");
2339 continue;
2340 }
2341 if trimmed == "/status" || trimmed == "/s" {
2342 ui.add_message(
2343 "System",
2344 &format!("Mode: AUTO | Tokens: {} | Time: {}s", ui.total_tokens, ui.elapsed_secs),
2345 );
2346 continue;
2347 }
2348 if trimmed == "/copy" || trimmed.starts_with("/copy ") {
2349 let args = trimmed.strip_prefix("/copy").unwrap_or("").trim();
2350 let result = execute_copy_command(args, memory, project_root).await;
2351 match result {
2352 Ok(msg) => ui.add_message("System", &msg),
2353 Err(e) => ui.add_message("Error", &format!("copy failed: {e}")),
2354 }
2355 continue;
2356 }
2357 if trimmed == "/clear" || trimmed == "/reset" || trimmed == "/new" {
2358 ui.messages.clear();
2359 ui.add_message("System", "Conversation cleared.");
2360 *memory = eli_core::memory::Memory::new(cfg.chat.mem_steps);
2362 memory.set_system(eli_core::contract::system_prompt());
2363 ui.total_tokens = 0;
2364 ui.clear_sources();
2365 continue;
2366 }
2367 if trimmed.is_empty() {
2368 continue;
2369 }
2370
2371 apply_tui_mode(ui.prompt_mode, cfg);
2373 ui.add_message("You", trimmed);
2374 store
2375 .append(
2376 session_id,
2377 &SessionEvent {
2378 ts: chrono::Utc::now(),
2379 kind: EventKind::UserMessage {
2380 content: trimmed.to_string(),
2381 },
2382 },
2383 )
2384 .await
2385 .ok();
2386 ui.is_processing = true;
2387 ui.clear_sources();
2388
2389 terminal.draw(&mut ui)?;
2391
2392 let (clean_prompt, images) = process_input_for_images(trimmed);
2393
2394 let result = run_agent_tui(
2396 &cfg.chat,
2397 adapter.clone(),
2398 diff_engine,
2399 command_runner,
2400 store,
2401 &paths.data_dir,
2402 session_id,
2403 project_root,
2404 memory,
2405 undo_stack,
2406 &mut ui,
2407 &mut terminal,
2408 AgentProfile::Coding,
2409 clean_prompt,
2410 images,
2411 ).await;
2412
2413 ui.is_processing = false;
2414
2415 if let Err(e) = result {
2416 let msg = format!("{:?}", e);
2417 ui.add_message("Error", &msg);
2418 store
2419 .append(
2420 session_id,
2421 &SessionEvent {
2422 ts: chrono::Utc::now(),
2423 kind: EventKind::Note { content: msg },
2424 },
2425 )
2426 .await
2427 .ok();
2428 }
2429
2430 while let Some(queued) = ui.pop_queued() {
2431 let trimmed = queued.trim();
2432 if trimmed.is_empty() {
2433 continue;
2434 }
2435 apply_tui_mode(ui.prompt_mode, cfg);
2436 ui.add_message("You", trimmed);
2437 store
2438 .append(
2439 session_id,
2440 &SessionEvent {
2441 ts: chrono::Utc::now(),
2442 kind: EventKind::UserMessage {
2443 content: trimmed.to_string(),
2444 },
2445 },
2446 )
2447 .await
2448 .ok();
2449 ui.is_processing = true;
2450 ui.clear_sources();
2451 terminal.draw(&mut ui)?;
2452
2453 let (clean_prompt, images) = process_input_for_images(trimmed);
2454 let queued_result = run_agent_tui(
2455 &cfg.chat,
2456 adapter.clone(),
2457 diff_engine,
2458 command_runner,
2459 store,
2460 &paths.data_dir,
2461 session_id,
2462 project_root,
2463 memory,
2464 undo_stack,
2465 &mut ui,
2466 &mut terminal,
2467 AgentProfile::Coding,
2468 clean_prompt,
2469 images,
2470 )
2471 .await;
2472
2473 ui.is_processing = false;
2474 if let Err(e) = queued_result {
2475 let msg = format!("{:?}", e);
2476 ui.add_message("Error", &msg);
2477 store
2478 .append(
2479 session_id,
2480 &SessionEvent {
2481 ts: chrono::Utc::now(),
2482 kind: EventKind::Note { content: msg },
2483 },
2484 )
2485 .await
2486 .ok();
2487 }
2488 }
2489 }
2490 }
2491 }
2492 _ => {}
2493 }
2494 }
2495
2496 if ui.should_quit {
2497 break;
2498 }
2499 }
2500
2501 Ok(())
2502}
2503
2504async fn run_agent_tui(
2506 chat: &eli_core::config::ChatConfig,
2507 adapter: Arc<dyn LlmAdapter>,
2508 _diff_engine: &DiffEngine,
2509 command_runner: &CommandRunner,
2510 store: &SessionStore,
2511 _data_dir: &Path,
2512 session_id: &str,
2513 _project_root: &Path,
2514 memory: &mut eli_core::memory::Memory,
2515 _undo_stack: &mut Vec<Vec<DiffResult>>,
2516 ui: &mut chat_ui::ChatUi,
2517 terminal: &mut chat_ui::ChatTerminal,
2518 _profile: AgentProfile,
2519 initial_message: String,
2520 images: Vec<String>,
2521) -> Result<()> {
2522 use eli_core::types::ChatStreamEvent;
2523 use futures::StreamExt;
2524 use crossterm::event as ct_event;
2525 use crossterm::event::{Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind as CtKeyEventKind};
2526
2527 let max_iters = if chat.auto { chat.max_auto.max(1) } else { 1 };
2528 let mut current_message = initial_message.clone();
2529 let mut current_images = images;
2530
2531 for step in 1..=max_iters {
2532 ui.tick_spinner();
2534 terminal.draw(ui)?;
2535
2536 if !current_images.is_empty() {
2538 memory.push(ChatMessage::user_with_images(current_message.clone(), current_images.clone()));
2539 current_images.clear();
2540 } else {
2541 memory.push(ChatMessage::user(current_message.clone()));
2542 }
2543
2544 let req = ChatRequest {
2546 messages: memory.context(),
2547 model: chat.model.clone(),
2548 max_tokens: chat.max_tokens,
2549 temperature: chat.temperature,
2550 response_format: None,
2551 stream: true,
2552 };
2553
2554 terminal.draw(ui)?;
2556
2557 let mut stream = adapter.chat_stream(req).await.context("start stream")?;
2558 let mut full_response = String::new();
2559 let mut interrupted = false;
2560
2561 let check_interrupt = |ui: &mut chat_ui::ChatUi| -> bool {
2562 if ui.interrupt_requested {
2563 ui.interrupt_requested = false;
2564 return true;
2565 }
2566 while ct_event::poll(Duration::from_millis(0)).unwrap_or(false) {
2567 let Ok(ev) = ct_event::read() else { continue; };
2568 match ev {
2569 CtEvent::Key(key) => {
2570 if key.kind != CtKeyEventKind::Press {
2571 continue;
2572 }
2573 if key.code == CtKeyCode::Esc {
2574 return true;
2575 }
2576 if let Some(input) = ui.handle_key(key.code, key.modifiers) {
2577 let trimmed = input.trim();
2578 if trimmed.eq_ignore_ascii_case("/exit") || trimmed.eq_ignore_ascii_case("/quit") {
2579 ui.should_quit = true;
2580 return true;
2581 }
2582 if !trimmed.is_empty() {
2583 ui.queue_prompt(trimmed.to_string());
2584 }
2585 }
2586 }
2587 CtEvent::Paste(text) => {
2588 ui.handle_paste(&text);
2589 }
2590 _ => {}
2591 }
2592 }
2593 false
2594 };
2595
2596 loop {
2597 tokio::select! {
2598 maybe_ev = stream.next() => {
2599 match maybe_ev {
2600 Some(Ok(ChatStreamEvent::Delta(text))) => {
2601 full_response.push_str(&text);
2602 }
2603 Some(Ok(ChatStreamEvent::Usage(usage))) => {
2604 ui.total_tokens = ui.total_tokens.saturating_add(usage.total_tokens);
2605 }
2606 Some(Ok(ChatStreamEvent::Done)) => break,
2607 Some(Err(e)) => {
2608 let msg = format!("Stream error: {:?}", e);
2609 ui.add_message("Error", &msg);
2610 store
2611 .append(
2612 session_id,
2613 &SessionEvent {
2614 ts: chrono::Utc::now(),
2615 kind: EventKind::Note { content: msg },
2616 },
2617 )
2618 .await
2619 .ok();
2620 break;
2621 }
2622 None => break,
2623 }
2624 }
2625 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
2626 }
2627
2628 if check_interrupt(ui) {
2629 interrupted = true;
2630 break;
2631 }
2632
2633 ui.tick_spinner();
2635 terminal.draw(ui)?;
2636 }
2637
2638 if interrupted {
2639 ui.add_message("System", "(interrupted)");
2640 store
2641 .append(
2642 session_id,
2643 &SessionEvent {
2644 ts: chrono::Utc::now(),
2645 kind: EventKind::Note {
2646 content: "(interrupted)".to_string(),
2647 },
2648 },
2649 )
2650 .await
2651 .ok();
2652 return Ok(());
2653 }
2654
2655 let model = match contract::validate_model_response(&full_response) {
2657 Ok(m) => m,
2658 Err(e) => {
2659 let msg = format!("Invalid response: {}", e);
2660 ui.add_message("Error", &msg);
2661 store
2662 .append(
2663 session_id,
2664 &SessionEvent {
2665 ts: chrono::Utc::now(),
2666 kind: EventKind::Note { content: msg },
2667 },
2668 )
2669 .await
2670 .ok();
2671 break;
2672 }
2673 };
2674
2675 memory.push(ChatMessage::assistant(full_response.clone()));
2677 store
2678 .append(
2679 session_id,
2680 &SessionEvent {
2681 ts: chrono::Utc::now(),
2682 kind: EventKind::AssistantMessage {
2683 content: full_response.clone(),
2684 },
2685 },
2686 )
2687 .await
2688 .ok();
2689
2690 if let Some(synthesis) = &model.synthesis {
2692 if !synthesis.answer.trim().is_empty() {
2693 ui.add_message("Eli", synthesis.answer.trim());
2694 }
2695 } else if !model.notes.trim().is_empty() {
2696 ui.add_message("Eli", model.notes.trim());
2697 }
2698
2699 if !model.commands.is_empty() && !matches!(chat.mode, RunMode::Read) {
2701 let mut all_tool_output = String::new();
2702
2703 for cmd in &model.commands {
2704 ui.add_message("Tool", &format!("$ {}", cmd));
2705 store
2706 .append(
2707 session_id,
2708 &SessionEvent {
2709 ts: chrono::Utc::now(),
2710 kind: EventKind::Note {
2711 content: format!("$ {}", cmd),
2712 },
2713 },
2714 )
2715 .await
2716 .ok();
2717 terminal.draw(ui)?;
2718
2719 let results = command_runner
2720 .run_commands(&[cmd.clone()])
2721 .await;
2722
2723 for r in &results {
2724 let icon = if r.returncode == 0 { "✓" } else { "✗" };
2725 let output = if !r.stdout.trim().is_empty() {
2726 r.stdout.lines().take(3).collect::<Vec<_>>().join("\n")
2727 } else if !r.stderr.trim().is_empty() {
2728 r.stderr.lines().take(2).collect::<Vec<_>>().join("\n")
2729 } else {
2730 String::new()
2731 };
2732 let line = format!("{} {}", icon, output);
2733 ui.add_message("Tool", &line);
2734 store
2735 .append(
2736 session_id,
2737 &SessionEvent {
2738 ts: chrono::Utc::now(),
2739 kind: EventKind::Note { content: line },
2740 },
2741 )
2742 .await
2743 .ok();
2744
2745 all_tool_output.push_str(&format!("Command: {}\n", cmd));
2747 all_tool_output.push_str(&format!("Return code: {}\n", r.returncode));
2748 all_tool_output.push_str(&format!("Digest: {}\n", build_command_digest(r)));
2749 if !r.stdout.trim().is_empty() {
2750 all_tool_output.push_str(&format!("Output:\n{}\n", r.stdout));
2751 }
2752 if !r.stderr.trim().is_empty() {
2753 all_tool_output.push_str(&format!("Stderr:\n{}\n", r.stderr));
2754 }
2755 all_tool_output.push('\n');
2756
2757 for source in infer_sources(cmd, &r.stdout) {
2759 ui.add_source(source);
2760 }
2761 ui.last_tool_ok = Some(r.returncode == 0);
2762 }
2763 terminal.draw(ui)?;
2764 }
2765
2766 if !all_tool_output.is_empty() {
2768 memory.push(ChatMessage::user(format!("Tool execution results:\n{}", all_tool_output)));
2769 }
2770 }
2771
2772 if matches!(model.status, StepStatus::Done) {
2774 break;
2775 }
2776
2777 current_message = "KEEP WORKING".to_string();
2779 }
2780
2781 Ok(())
2782}
2783
2784async fn cmd_chat(
2785 provider: Option<String>,
2786 model: Option<String>,
2787 display_override: Option<DisplayMode>,
2788) -> Result<()> {
2789 let paths = Paths::discover().context("discover paths")?;
2790 let mut cfg = config::load_or_create(&paths).context("load/create config")?;
2791 apply_overrides(&mut cfg, provider, model)?;
2792 ensure_tui_default_model(&mut cfg.chat);
2793 cfg.chat.approvals = ApprovalMode::Auto;
2794 cfg.chat.approvals_commands = None;
2795 cfg.chat.approvals_diffs = None;
2796 cfg.chat.auto_mode = AutoMode::Autonomous;
2797 if let Some(mode) = display_override {
2798 cfg.chat.display_mode = mode;
2799 }
2800
2801 let adapter = eli_adapters::build_from_chat_config(&cfg.chat).context("build adapter")?;
2802 let mut adapter: Arc<dyn LlmAdapter> = Arc::from(adapter);
2803
2804 let cwd = std::env::current_dir().context("get cwd")?;
2805 let project_root = cfg
2806 .chat
2807 .resolved_project_root(&cwd)
2808 .map_err(|e| anyhow::anyhow!(e))
2809 .context("resolve project root")?;
2810
2811 let diff_engine = DiffEngine::new(project_root.clone()).context("init diff engine")?;
2812 let command_runner = CommandRunner::new(
2813 cfg.chat.timeout_secs,
2814 cfg.chat.max_cmds,
2815 cfg.chat.parallel_commands,
2816 project_root.clone(),
2817 );
2818
2819 let store = SessionStore::new(&paths);
2820 let session_id = uuid::Uuid::new_v4().to_string();
2821 info!(session_id = %session_id, provider = %cfg.chat.provider, model = %cfg.chat.model, "starting chat");
2822
2823 let rl_config = Config::builder()
2824 .completion_type(CompletionType::Circular)
2825 .build();
2826 let shared_input_tokens = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2827 let mut editor: Editor<SlashHelper, DefaultHistory> =
2828 Editor::with_config(rl_config).context("init readline")?;
2829 editor.set_helper(Some(SlashHelper {
2830 last_input_tokens: shared_input_tokens.clone(),
2831 }));
2832 let slash_menu = SlashMenu::new();
2833 editor.bind_sequence(
2834 KeyEvent::from('/'),
2835 EventHandler::Conditional(Box::new(SlashMenuHandler::new(slash_menu.clone()))),
2836 );
2837 editor.bind_sequence(
2838 KeyEvent(KeyCode::Down, Modifiers::NONE),
2839 EventHandler::Conditional(Box::new(SlashNavHandler::new(
2840 slash_menu.clone(),
2841 SlashNav::Next,
2842 ))),
2843 );
2844 editor.bind_sequence(
2845 KeyEvent(KeyCode::Up, Modifiers::NONE),
2846 EventHandler::Conditional(Box::new(SlashNavHandler::new(
2847 slash_menu.clone(),
2848 SlashNav::Prev,
2849 ))),
2850 );
2851 let mut memory = eli_core::memory::Memory::new(cfg.chat.mem_steps);
2852 memory.set_system(eli_core::contract::system_prompt());
2853 ensure_eli_research_brain(&project_root).context("ensure eli_research/ELI.md")?;
2854 let mut undo_stack: Vec<Vec<DiffResult>> = Vec::new();
2855 let mut state = SessionState::new(&cfg.chat);
2856 state.load_recent_research(&project_root, 12);
2857 let force_plain_prompt = matches!(cfg.chat.display_mode, DisplayMode::Debug);
2858
2859 if matches!(cfg.chat.display_mode, DisplayMode::Standard) {
2861 return run_chat_tui(
2862 &mut cfg,
2863 adapter,
2864 &diff_engine,
2865 &command_runner,
2866 &store,
2867 &paths,
2868 &session_id,
2869 &project_root,
2870 &mut memory,
2871 &mut undo_stack,
2872 ).await;
2873 }
2874
2875 print_banner(&cfg.chat, &project_root, &state);
2877
2878 loop {
2879 let queue_len = state.prompt_queue.len();
2881
2882 if let Some(usage) = &state.last_usage {
2884 shared_input_tokens.store(usage.prompt_tokens as usize, std::sync::atomic::Ordering::Relaxed);
2885 }
2886
2887 let (line, from_boxed_prompt) = if let Some(queued) = state.next_prompt() {
2888 print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, queued));
2889 (queued, false)
2890 } else if matches!(state.display_mode, DisplayMode::Standard) && !force_plain_prompt {
2891 let Some(line) = read_line_boxed(&mut state, &mut cfg.chat, queue_len).context("boxed prompt")? else {
2892 break;
2893 };
2894 (line, true)
2895 } else {
2896 let prompt_prefix = if force_plain_prompt {
2897 "› ".to_string()
2898 } else if queue_len > 0 {
2899 format!("[{}Q] › ", queue_len)
2900 } else {
2901 "› ".to_string()
2902 };
2903
2904 slash_menu.reset();
2905
2906 let res = editor.readline_with_initial(&prompt_prefix, (&state.input_buffer, ""));
2907 state.input_buffer.clear();
2908
2909 let line = match res {
2910 Ok(line) => line,
2911 Err(ReadlineError::Interrupted) => {
2912 println!();
2913 continue;
2914 }
2915 Err(ReadlineError::Eof) => break,
2916 Err(e) => return Err(e).context("readline failed"),
2917 };
2918 (line, false)
2919 };
2920
2921 let trimmed = line.trim();
2922 if trimmed.is_empty() {
2923 continue;
2924 }
2925
2926 if from_boxed_prompt {
2927 print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, trimmed));
2928 }
2929 state.prompt_history.push(trimmed.to_string());
2930 editor.add_history_entry(trimmed).ok();
2931
2932 if trimmed == "/exit" || trimmed == "/quit" {
2934 break;
2935 }
2936 if trimmed == "/queue" || trimmed == "/q" {
2937 if state.prompt_queue.is_empty() {
2938 println!("(queue empty)");
2939 } else {
2940 println!("Queue:");
2941 for (i, p) in state.prompt_queue.iter().enumerate() {
2942 println!(" {}. {}", i + 1, p);
2943 }
2944 }
2945 continue;
2946 }
2947 if trimmed.starts_with("/q ") || trimmed.starts_with("/queue ") {
2948 let rest = trimmed.splitn(2, ' ').nth(1).unwrap_or("");
2949 if !rest.is_empty() {
2950 state.queue_prompt(rest.to_string());
2951 println!("(added to queue: position {})", state.queue_len());
2952 }
2953 continue;
2954 }
2955 if trimmed == "/clear-queue" || trimmed == "/cq" {
2956 state.prompt_queue.clear();
2957 println!("(queue cleared)");
2958 continue;
2959 }
2960 if trimmed == "/compact" {
2961 match compact_memory_now(adapter.clone(), &cfg.chat, &mut memory).await {
2962 Ok(Some(compaction)) => {
2963 let note = format!(
2964 "memory_compaction: dropped {} messages\n{}",
2965 compaction.dropped,
2966 compaction.summary
2967 );
2968 let brain_entry = format!(
2969 "\n### {} (session {})\n{}\n",
2970 chrono::Utc::now().to_rfc3339(),
2971 session_id,
2972 note
2973 );
2974 if let Err(e) = append_eli_brain(&project_root, &brain_entry) {
2975 println!("(compacted, but failed to write brain: {e})");
2976 } else {
2977 println!("memory: compacted ({} msgs)", compaction.dropped);
2978 }
2979 store
2980 .append(
2981 &session_id,
2982 &SessionEvent {
2983 ts: chrono::Utc::now(),
2984 kind: EventKind::Note { content: note },
2985 },
2986 )
2987 .await
2988 .ok();
2989 }
2990 Ok(None) => println!("(nothing to compact)"),
2991 Err(e) => println!("(compact failed: {e})"),
2992 }
2993 continue;
2994 }
2995 if trimmed == "/tip" {
2996 println!("(tips are only shown in standard TUI mode)");
2997 continue;
2998 }
2999 if trimmed == "/reset" || trimmed == "/new" {
3000 memory = eli_core::memory::Memory::new(cfg.chat.mem_steps);
3001 memory.set_system(eli_core::contract::system_prompt());
3002 ensure_eli_research_brain(&project_root).ok();
3003 state.total_work_time = Duration::ZERO;
3004 state.step_count = 0;
3005 state.total_usage = eli_core::types::Usage::default();
3006 state.last_usage = None;
3007 println!("(reset)");
3008 continue;
3009 }
3010 if trimmed == "/brain" {
3011 state.display_mode = DisplayMode::Brain;
3012 println!("(brain mode: full output)");
3013 continue;
3014 }
3015 if trimmed == "/debug" {
3016 state.display_mode = DisplayMode::Debug;
3017 println!("(debug mode: raw request/response + tool output + observation)");
3018 continue;
3019 }
3020 if trimmed == "/standard" || trimmed == "/brief" {
3021 state.display_mode = DisplayMode::Standard;
3022 println!("(standard mode: brief output)");
3023 continue;
3024 }
3025 if trimmed == "/read" {
3026 cfg.chat.mode = RunMode::Read;
3027 println!("(exec mode: read)");
3028 continue;
3029 }
3030 if trimmed == "/work" {
3031 cfg.chat.mode = RunMode::Work;
3032 println!("(exec mode: work)");
3033 continue;
3034 }
3035 if trimmed == "/bot" {
3036 cfg.chat.mode = RunMode::Work;
3037 cfg.chat.approvals = ApprovalMode::Auto;
3038 cfg.chat.approvals_commands = Some(ApprovalMode::Auto);
3039 cfg.chat.approvals_diffs = Some(ApprovalMode::Ask);
3040 println!("(bot: exec=work, approvals={})", format_approvals_display(&cfg.chat));
3041 continue;
3042 }
3043 if trimmed == "/yolo" {
3044 cfg.chat.mode = RunMode::Work;
3045 cfg.chat.approvals = ApprovalMode::Auto;
3046 cfg.chat.approvals_commands = None;
3047 cfg.chat.approvals_diffs = None;
3048 println!("(yolo: exec=work, approvals={})", format_approvals_display(&cfg.chat));
3049 continue;
3050 }
3051 if trimmed == "/mode" || trimmed.starts_with("/mode ") {
3052 let mode = trimmed
3053 .split_whitespace()
3054 .nth(1)
3055 .unwrap_or("")
3056 .to_ascii_lowercase();
3057 if mode.is_empty() {
3058 println!("exec mode: {}", format_mode(cfg.chat.mode));
3059 } else if mode == "read" {
3060 cfg.chat.mode = RunMode::Read;
3061 println!("(exec mode: read)");
3062 } else if mode == "work" {
3063 cfg.chat.mode = RunMode::Work;
3064 println!("(exec mode: work)");
3065 } else {
3066 println!("(mode must be read or work)");
3067 }
3068 continue;
3069 }
3070 if trimmed == "/model" || trimmed.starts_with("/model ") {
3071 let model = trimmed.strip_prefix("/model").unwrap_or("").trim();
3072 if model.is_empty() {
3073 print_history_block(vec![format!("model: {}", cfg.chat.model)]);
3074 } else {
3075 cfg.chat.model = model.to_string();
3076 print_history_block(vec![format!("(model: {})", cfg.chat.model)]);
3077 }
3078 continue;
3079 }
3080 if trimmed == "/models" {
3081 print_history_block(vec![
3082 format!("model: {}", cfg.chat.model),
3083 "set with: /model <name>".to_string(),
3084 ]);
3085 continue;
3086 }
3087 if trimmed == "/key" || trimmed.starts_with("/key ") {
3088 let key = trimmed.strip_prefix("/key").unwrap_or("").trim();
3089 if key.is_empty() {
3090 println!("usage: /key <api-key>");
3091 continue;
3092 }
3093 match cfg.chat.provider {
3094 ProviderKind::Anthropic => cfg.chat.anthropic_api_key = Some(key.to_string()),
3095 ProviderKind::OpenAI => cfg.chat.openai_api_key = Some(key.to_string()),
3096 ProviderKind::OpenRouter => cfg.chat.openrouter_api_key = Some(key.to_string()),
3097 ProviderKind::Ollama | ProviderKind::Mock => {
3098 println!("(no API key needed for {})", cfg.chat.provider);
3099 continue;
3100 }
3101 }
3102 adapter = Arc::from(
3103 eli_adapters::build_from_chat_config(&cfg.chat).context("build adapter")?,
3104 );
3105 println!("(api key set for {} - session only)", cfg.chat.provider);
3106 continue;
3107 }
3108 if trimmed == "/status" || trimmed == "/s" {
3109 print_mode_status(&state, &cfg.chat);
3110 print_cost_stats(&state, &cfg.chat);
3111 continue;
3112 }
3113 if trimmed == "/$" {
3114 print_cost_stats(&state, &cfg.chat);
3115 continue;
3116 }
3117 if trimmed == "/help" || trimmed == "/?" {
3118 print_help();
3119 continue;
3120 }
3121 if trimmed == "/undo" {
3122 perform_undo(&mut undo_stack, &mut memory, &store, &session_id).await?;
3123 continue;
3124 }
3125
3126 if trimmed.starts_with('+') {
3128 let queued = trimmed[1..].trim().to_string();
3129 if !queued.is_empty() {
3130 state.queue_prompt(queued);
3131 println!("(queued, {} in queue)", state.queue_len());
3132 }
3133 continue;
3134 }
3135
3136 let (clean_prompt, images) = process_input_for_images(trimmed);
3138 run_agent_steps(
3140 &cfg.chat,
3141 adapter.clone(),
3142 &diff_engine,
3143 &command_runner,
3144 &store,
3145 &paths.data_dir,
3146 &session_id,
3147 &project_root,
3148 &mut memory,
3149 &mut undo_stack,
3150 &mut state,
3151 AgentProfile::Coding,
3152 clean_prompt,
3153 images,
3154 )
3155 .await?;
3156
3157 while let Some(queued_prompt) = state.next_prompt() {
3159 print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, queued_prompt));
3160 let (q_clean, q_images) = process_input_for_images(&queued_prompt);
3163
3164 run_agent_steps(
3165 &cfg.chat,
3166 adapter.clone(),
3167 &diff_engine,
3168 &command_runner,
3169 &store,
3170 &paths.data_dir,
3171 &session_id,
3172 &project_root,
3173 &mut memory,
3174 &mut undo_stack,
3175 &mut state,
3176 AgentProfile::Coding,
3177 q_clean,
3178 q_images,
3179 )
3180 .await?;
3181 }
3182 }
3183
3184 Ok(())
3185}
3186
3187fn read_line_boxed(
3188 state: &mut SessionState,
3189 chat: &mut eli_core::config::ChatConfig,
3190 queue_len: usize,
3191) -> Result<Option<String>> {
3192 let mut input_buffer = std::mem::take(&mut state.input_buffer);
3193 let mut cursor_pos = state.cursor_pos.min(input_buffer.len());
3194 let mut history_cursor = state.history_cursor;
3195
3196 let start = Instant::now();
3197 let mut spinner_idx = 0usize;
3198 let mut last_anim = Instant::now();
3199 let mut footer = FooterUi::enable();
3200 let mut esc_armed = false;
3201 let mut esc_deadline = Instant::now();
3202
3203 let render = |footer: &mut FooterUi,
3204 spinner_idx: usize,
3205 input_buffer: &str,
3206 cursor_pos: usize,
3207 state: &SessionState,
3208 chat: &eli_core::config::ChatConfig| {
3209 let title = footer_title(
3210 "ready",
3211 spinner_idx,
3212 queue_len,
3213 start.elapsed(),
3214 state.total_usage.total_tokens,
3215 Some(prompt_mode(state, chat)),
3216 );
3217 footer.render(&title, input_buffer, cursor_pos);
3218 };
3219
3220 render(&mut footer, spinner_idx, &input_buffer, cursor_pos, state, chat);
3221
3222 let maybe_line = loop {
3223 if esc_armed && Instant::now() > esc_deadline {
3224 esc_armed = false;
3225 }
3226 if last_anim.elapsed() > Duration::from_millis(120) {
3227 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
3228 render(&mut footer, spinner_idx, &input_buffer, cursor_pos, state, chat);
3229 last_anim = Instant::now();
3230 }
3231
3232 if !ct_event::poll(Duration::from_millis(40)).unwrap_or(false) {
3233 continue;
3234 }
3235
3236 let event = match ct_event::read() {
3237 Ok(ev) => ev,
3238 Err(_) => continue,
3239 };
3240
3241 match event {
3242 CtEvent::Resize(_, _) => {
3243 render(&mut footer, spinner_idx, &input_buffer, cursor_pos, state, chat);
3244 continue;
3245 }
3246 CtEvent::Key(key) => {
3247 if key.kind != KeyEventKind::Press {
3248 continue;
3249 }
3250
3251 if key.modifiers.contains(CtKeyModifiers::CONTROL) {
3252 match key.code {
3253 CtKeyCode::Char('c') => {
3254 input_buffer.clear();
3255 cursor_pos = 0;
3256 history_cursor = None;
3257 esc_armed = false;
3258 break Some(String::new());
3259 }
3260 CtKeyCode::Char('d') => {
3261 break None;
3262 }
3263 _ => {}
3264 }
3265 }
3266
3267 match key.code {
3268 CtKeyCode::Char(c) => {
3269 history_cursor = None;
3270 input_buffer.insert(cursor_pos, c);
3271 cursor_pos += 1;
3272 esc_armed = false;
3273 }
3274 CtKeyCode::Backspace => {
3275 history_cursor = None;
3276 if cursor_pos > 0 {
3277 cursor_pos -= 1;
3278 input_buffer.remove(cursor_pos);
3279 }
3280 esc_armed = false;
3281 }
3282 CtKeyCode::Delete => {
3283 history_cursor = None;
3284 if cursor_pos < input_buffer.len() {
3285 input_buffer.remove(cursor_pos);
3286 }
3287 esc_armed = false;
3288 }
3289 CtKeyCode::Left => {
3290 if cursor_pos > 0 {
3291 cursor_pos -= 1;
3292 }
3293 esc_armed = false;
3294 }
3295 CtKeyCode::Right => {
3296 if cursor_pos < input_buffer.len() {
3297 cursor_pos += 1;
3298 }
3299 esc_armed = false;
3300 }
3301 CtKeyCode::Home => {
3302 cursor_pos = 0;
3303 esc_armed = false;
3304 }
3305 CtKeyCode::End => {
3306 cursor_pos = input_buffer.len();
3307 esc_armed = false;
3308 }
3309 CtKeyCode::Up => {
3310 let Some(last_idx) = state.prompt_history.len().checked_sub(1) else {
3311 continue;
3312 };
3313 let next = match history_cursor {
3314 None => Some(last_idx),
3315 Some(idx) => idx.checked_sub(1),
3316 };
3317 if let Some(idx) = next {
3318 history_cursor = Some(idx);
3319 input_buffer = state.prompt_history[idx].clone();
3320 cursor_pos = input_buffer.len(); }
3322 esc_armed = false;
3323 }
3324 CtKeyCode::Down => {
3325 let Some(idx) = history_cursor else {
3326 continue;
3327 };
3328 let next = idx.saturating_add(1);
3329 if next >= state.prompt_history.len() {
3330 history_cursor = None;
3331 input_buffer.clear();
3332 cursor_pos = 0;
3333 } else {
3334 history_cursor = Some(next);
3335 input_buffer = state.prompt_history[next].clone();
3336 cursor_pos = input_buffer.len(); }
3338 esc_armed = false;
3339 }
3340 CtKeyCode::Esc => {
3341 if !esc_armed {
3342 esc_armed = true;
3343 esc_deadline = Instant::now() + Duration::from_millis(800);
3344 } else {
3345 history_cursor = None;
3346 input_buffer.clear();
3347 cursor_pos = 0;
3348 esc_armed = false;
3349 }
3350 }
3351 CtKeyCode::Enter => {
3352 let line = input_buffer.clone();
3353 history_cursor = None;
3354 input_buffer.clear();
3355 cursor_pos = 0;
3356 esc_armed = false;
3357 break Some(line);
3358 }
3359 _ => {}
3360 }
3361
3362 render(&mut footer, spinner_idx, &input_buffer, cursor_pos, state, chat);
3363 }
3364 _ => {}
3365 }
3366 };
3367
3368 state.input_buffer = input_buffer;
3369 state.cursor_pos = cursor_pos;
3370 state.history_cursor = history_cursor;
3371
3372 Ok(maybe_line)
3373}
3374
3375fn print_mode_status(state: &SessionState, chat: &eli_core::config::ChatConfig) {
3376 let display = match state.display_mode {
3377 DisplayMode::Standard => "standard",
3378 DisplayMode::Brain => "brain",
3379 DisplayMode::Debug => "debug",
3380 DisplayMode::Raw => "raw",
3381 };
3382 let agent = "autonomous (locked)";
3383 let exec = format_mode(chat.mode);
3384 let approvals = format_approvals_display(chat);
3385 let auto_run = if chat.auto { "on" } else { "off" };
3386 let time = format_duration(state.total_work_time);
3387
3388 let body = format!(
3389 "display: {display}\nagent: {agent}\nexec: {exec}\napprovals: {approvals}\nauto-run: {auto_run}\nsteps: {}\ntime: {time}",
3390 state.step_count
3391 );
3392 println!("{}", render_ratatui_panel("status", &body));
3393}
3394
3395fn print_help() {
3396 use style::*;
3397
3398 let lines = vec![
3399 format!("{}{}Commands{}", BOLD, CYAN, RESET),
3400 String::new(),
3401 format!("{}Display{}", PURPLE, RESET),
3402 format!(" {}/brain{} full output (tools, history, details)", WHITE, RESET),
3403 format!(" {}/debug{} debug output (raw request/response + tool output + observation)", WHITE, RESET),
3404 format!(" {}/standard{} brief output (recent stream, summary)", WHITE, RESET),
3405 String::new(),
3406 format!("{}Execution{}", PURPLE, RESET),
3407 format!(" {}/mode{} set exec mode (read/work)", WHITE, RESET),
3408 format!(" {}/read{} set exec mode to read", WHITE, RESET),
3409 format!(" {}/work{} set exec mode to work", WHITE, RESET),
3410 format!(" {}/bot{} work; cmds auto, diffs ask", WHITE, RESET),
3411 format!(" {}/yolo{} work; auto approvals", WHITE, RESET),
3412 String::new(),
3413 format!("{}Configuration{}", PURPLE, RESET),
3414 format!(" {}/model{} set or show model for this session", WHITE, RESET),
3415 format!(" {}/key{} set API key for current provider", WHITE, RESET),
3416 String::new(),
3417 format!("{}Queue{}", PURPLE, RESET),
3418 format!(" {}/queue /q{} show queued prompts", WHITE, RESET),
3419 format!(" {}/cq{} clear queue", WHITE, RESET),
3420 format!(" {}+<prompt>{} queue a prompt for later", WHITE, RESET),
3421 String::new(),
3422 format!("{}Keyboard{}", PURPLE, RESET),
3423 format!(" {}Esc{} interrupt current run (standard mode)", WHITE, RESET),
3424 format!(" {}Esc Esc{} clear input (standard mode)", WHITE, RESET),
3425 format!(" {}Ctrl+C{} clear input (standard mode)", WHITE, RESET),
3426 format!(" {}Ctrl+D{} quit (standard mode)", WHITE, RESET),
3427 String::new(),
3428 format!("{}Session{}", PURPLE, RESET),
3429 format!(" {}/status /s{} show current mode/stats", WHITE, RESET),
3430 format!(" {}/compact{} summarize older context (reduce tokens)", WHITE, RESET),
3431 format!(" {}/reset{} clear conversation", WHITE, RESET),
3432 format!(" {}/new{} alias for /reset", WHITE, RESET),
3433 format!(" {}/tip{} toggle tips (standard mode)", WHITE, RESET),
3434 format!(" {}/undo{} undo last edit", WHITE, RESET),
3435 format!(" {}/exit{} quit", WHITE, RESET),
3436 ];
3437
3438 let out = format_indented_block(&lines);
3439 println!("{}", out);
3440}
3441
3442async fn perform_undo(
3443 undo_stack: &mut Vec<Vec<DiffResult>>,
3444 memory: &mut eli_core::memory::Memory,
3445 store: &SessionStore,
3446 session_id: &str,
3447) -> Result<()> {
3448 let Some(last) = undo_stack.pop() else {
3449 println!("(nothing to undo)");
3450 return Ok(());
3451 };
3452
3453 let messages = UndoManager::undo_step(&last);
3454 if messages.is_empty() {
3455 println!("(nothing to undo)");
3456 return Ok(());
3457 }
3458
3459 for msg in &messages {
3460 println!("{msg}");
3461 }
3462
3463 let observation = format!("undo:\n{}", messages.join("\n"));
3464 memory.push(ChatMessage::tool(observation.clone(), "eli"));
3465 store
3466 .append(
3467 session_id,
3468 &SessionEvent {
3469 ts: chrono::Utc::now(),
3470 kind: EventKind::Note { content: observation },
3471 },
3472 )
3473 .await
3474 .ok();
3475
3476 Ok(())
3477}
3478
3479fn ensure_eli_research_brain(project_root: &Path) -> Result<PathBuf> {
3480 let dir = project_root.join("eli_research");
3481 std::fs::create_dir_all(&dir).context("create eli_research dir")?;
3482
3483 let brain = dir.join("ELI.md");
3484 const PINNED_START: &str = "<!-- ELI_PINNED_START -->";
3485 const PINNED_END: &str = "<!-- ELI_PINNED_END -->";
3486
3487 let pinned_block = format!(
3488 "{PINNED_START}\n\
3489## Default Research Flow\n\
3490- If ticker/company is ambiguous: `eli finance search --query <name>`\n\
3491- Start with price/volume: `eli finance timeseries` (zoom out, then zoom in). Identify key move dates.\n\
3492- Only then pull catalysts: `eli finance news --date YYYY-MM-DD` / `eli finance filings` for those key dates. News only matters if it moved price.\n\
3493- If the user mentions specific dates/days, include them (or ask 1 clarification).\n\
3494{PINNED_END}\n\
3495\n\
3496<!-- Append-only log below (eli writes here). -->\n"
3497 );
3498
3499 if brain.exists() {
3500 let content = std::fs::read_to_string(&brain).unwrap_or_default();
3502 if content.contains(PINNED_START) && content.contains(PINNED_END) {
3503 return Ok(brain);
3504 }
3505 let mut out = String::new();
3506 out.push_str(&pinned_block);
3507 if !content.trim().is_empty() {
3508 out.push_str("\n");
3509 out.push_str(&content);
3510 }
3511 std::fs::write(&brain, out).context("seed eli_research/ELI.md")?;
3512 return Ok(brain);
3513 }
3514
3515 std::fs::write(&brain, pinned_block).context("create eli_research/ELI.md")?;
3516 Ok(brain)
3517}
3518
3519fn read_eli_brain_tail(project_root: &Path, max_chars: usize) -> Result<Option<String>> {
3520 const MAX_LOG_ENTRIES: usize = 5;
3521 const LOG_MARKER: &str = "<!-- Append-only log below (eli writes here). -->";
3522
3523 let brain = ensure_eli_research_brain(project_root)?;
3524 let content = std::fs::read_to_string(&brain).context("read eli_research/ELI.md")?;
3525 if content.trim().is_empty() {
3526 return Ok(None);
3527 }
3528
3529 let log_slice = if let Some(idx) = content.find(LOG_MARKER) {
3530 &content[idx + LOG_MARKER.len()..]
3531 } else {
3532 content.as_str()
3533 };
3534
3535 let mut entries: Vec<String> = Vec::new();
3536 let mut current: Vec<String> = Vec::new();
3537 for line in log_slice.lines() {
3538 if line.starts_with("### ") {
3539 if !current.is_empty() {
3540 entries.push(current.join("\n"));
3541 current.clear();
3542 }
3543 current.push(line.to_string());
3544 } else if !current.is_empty() {
3545 current.push(line.to_string());
3546 }
3547 }
3548 if !current.is_empty() {
3549 entries.push(current.join("\n"));
3550 }
3551
3552 if entries.is_empty() {
3553 return Ok(None);
3554 }
3555
3556 let start = entries.len().saturating_sub(MAX_LOG_ENTRIES);
3557 let mut recent = entries[start..].join("\n\n");
3558 recent = recent.trim().to_string();
3559 if recent.is_empty() {
3560 return Ok(None);
3561 }
3562
3563 if max_chars == 0 {
3564 return Ok(Some(recent));
3565 }
3566
3567 let total = recent.chars().count();
3568 if total <= max_chars {
3569 return Ok(Some(recent));
3570 }
3571
3572 let tail: String = recent.chars().skip(total - max_chars).collect();
3573 Ok(Some(format!("…\n{tail}")))
3574}
3575
3576fn read_eli_brain_pinned(project_root: &Path, max_chars: usize) -> Result<Option<String>> {
3577 const PINNED_START: &str = "<!-- ELI_PINNED_START -->";
3578 const PINNED_END: &str = "<!-- ELI_PINNED_END -->";
3579
3580 let brain = ensure_eli_research_brain(project_root)?;
3581 let content = std::fs::read_to_string(&brain).context("read eli_research/ELI.md")?;
3582
3583 let Some(start) = content.find(PINNED_START) else {
3584 return Ok(None);
3585 };
3586 let after_start = &content[start + PINNED_START.len()..];
3587 let Some(end_rel) = after_start.find(PINNED_END) else {
3588 return Ok(None);
3589 };
3590 let pinned = after_start[..end_rel].trim();
3591 if pinned.is_empty() {
3592 return Ok(None);
3593 }
3594
3595 if max_chars == 0 {
3596 return Ok(Some(pinned.to_string()));
3597 }
3598
3599 let total = pinned.chars().count();
3600 if total <= max_chars {
3601 return Ok(Some(pinned.to_string()));
3602 }
3603
3604 let truncated: String = pinned.chars().take(max_chars).collect();
3605 Ok(Some(format!("{truncated}…")))
3606}
3607
3608fn read_eli_brain_context(project_root: &Path, pinned_max: usize, tail_max: usize) -> Result<Option<String>> {
3609 let pinned = match read_eli_brain_pinned(project_root, pinned_max) {
3610 Ok(v) => v,
3611 Err(e) => {
3612 warn!("eli brain: failed to read pinned (ignored): {e}");
3613 None
3614 }
3615 };
3616 let tail = match read_eli_brain_tail(project_root, tail_max) {
3617 Ok(v) => v,
3618 Err(e) => {
3619 warn!("eli brain: failed to read tail (ignored): {e}");
3620 None
3621 }
3622 };
3623
3624 match (pinned, tail) {
3625 (None, None) => Ok(None),
3626 (Some(pinned), None) => Ok(Some(format!("ELI.md (pinned):\n{pinned}"))),
3627 (None, Some(tail)) => Ok(Some(format!("ELI.md (recent):\n{tail}"))),
3628 (Some(pinned), Some(tail)) => Ok(Some(format!("ELI.md (pinned):\n{pinned}\n\nELI.md (recent):\n{tail}"))),
3629 }
3630}
3631
3632fn append_eli_brain(project_root: &Path, entry: &str) -> Result<()> {
3633 let brain = ensure_eli_research_brain(project_root)?;
3634
3635 let mut f = std::fs::OpenOptions::new()
3636 .create(true)
3637 .append(true)
3638 .open(&brain)
3639 .context("open eli_research/ELI.md")?;
3640
3641 use std::io::Write;
3642 f.write_all(entry.as_bytes())
3643 .context("append eli_research/ELI.md")?;
3644 if !entry.ends_with('\n') {
3645 f.write_all(b"\n")
3646 .context("append newline to eli_research/ELI.md")?;
3647 }
3648 Ok(())
3649}
3650
3651async fn execute_copy_command(
3653 args: &str,
3654 memory: &eli_core::memory::Memory,
3655 project_root: &Path,
3656) -> Result<String> {
3657 use eli_core::types::Role;
3658
3659 let parts: Vec<&str> = args.split_whitespace().collect();
3661
3662 let (scope_parts, output_file) = if let Some(idx) = parts.iter().position(|&p| p == ">") {
3664 let (scope, rest) = parts.split_at(idx);
3665 let file = rest.get(1).map(|s| s.to_string());
3666 (scope.to_vec(), file)
3667 } else {
3668 (parts, None)
3669 };
3670
3671 let scope = scope_parts.first().copied().unwrap_or("");
3673
3674 let exclude_data = scope_parts.iter().any(|&p| p == "-data");
3676 let exclude_meta = scope_parts.iter().any(|&p| p == "-meta");
3677
3678 let messages = memory.context();
3680
3681 let filtered: Vec<_> = match scope {
3683 "" | "last" => {
3684 messages.iter()
3686 .rev()
3687 .find(|m| m.role == Role::Assistant)
3688 .into_iter()
3689 .collect()
3690 }
3691 "all" => {
3692 messages.iter()
3694 .filter(|m| m.role != Role::System)
3695 .collect()
3696 }
3697 "user" => {
3698 messages.iter()
3699 .filter(|m| m.role == Role::User)
3700 .collect()
3701 }
3702 "assistant" => {
3703 messages.iter()
3704 .filter(|m| m.role == Role::Assistant)
3705 .collect()
3706 }
3707 "tools" => {
3708 messages.iter()
3709 .filter(|m| m.role == Role::Tool)
3710 .collect()
3711 }
3712 n if n.parse::<usize>().is_ok() => {
3713 let n: usize = n.parse().unwrap();
3715 let non_system: Vec<_> = messages.iter()
3716 .filter(|m| m.role != Role::System)
3717 .collect();
3718 non_system.into_iter().rev().take(n * 2).collect::<Vec<_>>().into_iter().rev().collect()
3719 }
3720 _ => {
3721 return Err(anyhow::anyhow!("unknown scope '{}'. Use: all, last, user, assistant, tools, or N", scope));
3722 }
3723 };
3724
3725 if filtered.is_empty() {
3726 return Ok("Nothing to copy.".to_string());
3727 }
3728
3729 let mut output = String::new();
3731 for msg in filtered {
3732 let role_str = match msg.role {
3733 Role::User => "## User",
3734 Role::Assistant => "## Assistant",
3735 Role::Tool => &format!("### Tool: {}", msg.name.as_deref().unwrap_or("unknown")),
3736 Role::System => continue, };
3738
3739 output.push_str(role_str);
3740 output.push_str("\n\n");
3741
3742 let content = if exclude_data && msg.content.len() > 2000 && msg.role == Role::Tool {
3743 format!("[output: {} chars, omitted with -data]\n", msg.content.len())
3744 } else {
3745 msg.content.clone()
3746 };
3747
3748 output.push_str(&content);
3749 output.push_str("\n\n");
3750 }
3751
3752 let char_count = output.len();
3753
3754 if let Some(file_path) = output_file {
3756 let full_path = project_root.join(&file_path);
3757 std::fs::write(&full_path, &output)
3758 .with_context(|| format!("write to {}", full_path.display()))?;
3759 Ok(format!("Copied {} chars to {}", char_count, file_path))
3760 } else {
3761 eli_screen::clipboard_set(&output).await
3763 .map_err(|e| anyhow::anyhow!("clipboard: {}", e))?;
3764 Ok(format!("Copied {} chars to clipboard", char_count))
3765 }
3766}
3767
3768fn slugify_for_filename(input: &str, max_len: usize) -> String {
3769 let mut out = String::new();
3770 let mut last_was_sep = false;
3771
3772 for ch in input.chars() {
3773 let c = ch.to_ascii_lowercase();
3774 if c.is_ascii_alphanumeric() {
3775 out.push(c);
3776 last_was_sep = false;
3777 } else if matches!(c, ' ' | '-' | '_' | '.' | '/' | '\\' | ':' | ';' | ',' | '|') {
3778 if !out.is_empty() && !last_was_sep {
3779 out.push('_');
3780 last_was_sep = true;
3781 }
3782 }
3783
3784 if max_len > 0 && out.len() >= max_len {
3785 break;
3786 }
3787 }
3788
3789 while out.ends_with('_') {
3790 out.pop();
3791 }
3792
3793 out
3794}
3795
3796fn write_research_report_md(
3797 project_root: &Path,
3798 session_id: &str,
3799 chat: &eli_core::config::ChatConfig,
3800 prompt: &str,
3801 synthesis: Option<&eli_core::contract::Synthesis>,
3802 status: &str,
3803 partial_output: Option<&str>,
3804) -> Result<Option<PathBuf>> {
3805 let dir = project_root.join("eli_research");
3806 std::fs::create_dir_all(&dir).context("create eli_research dir")?;
3807 let _ = ensure_eli_research_brain(project_root)?;
3808
3809 let now = chrono::Utc::now();
3810 let ts = now.format("%Y%m%d_%H%M%S").to_string();
3811 let session_short: String = session_id.chars().take(8).collect();
3812
3813 let title = prompt.trim();
3814 let title = if title.is_empty() { "Research" } else { title };
3815 let title_line = truncate(title, 120);
3816
3817 let slug = slugify_for_filename(title, 60);
3818 let filename = if slug.is_empty() {
3819 format!("research_{ts}_{session_short}.md")
3820 } else {
3821 format!("research_{ts}_{slug}_{session_short}.md")
3822 };
3823 let path = dir.join(filename);
3824
3825 let mut md = String::new();
3826 md.push_str(&format!("# {title_line}\n\n"));
3827 md.push_str(&format!("- Date (UTC): {}\n", now.to_rfc3339()));
3828 md.push_str(&format!("- Session: `{session_id}`\n"));
3829 md.push_str(&format!("- Provider: `{}`\n", chat.provider));
3830 md.push_str(&format!("- Model: `{}`\n", chat.model));
3831 md.push_str(&format!("- Status: {status}\n\n"));
3832
3833 md.push_str("## Prompt\n");
3834 md.push_str("```\n");
3835 md.push_str(prompt.trim());
3836 md.push_str("\n```\n\n");
3837
3838 if let Some(s) = synthesis {
3839 if !s.summary.is_empty() {
3840 md.push_str("## Summary\n");
3841 for item in &s.summary {
3842 let item = item.trim();
3843 if !item.is_empty() {
3844 md.push_str("- ");
3845 md.push_str(item);
3846 md.push('\n');
3847 }
3848 }
3849 md.push('\n');
3850 }
3851
3852 if !s.answer.trim().is_empty() {
3853 md.push_str("## Answer\n\n");
3854 md.push_str(s.answer.trim());
3855 md.push_str("\n\n");
3856 }
3857
3858 if !s.next_steps.is_empty() {
3859 md.push_str("## Next Steps\n");
3860 for item in &s.next_steps {
3861 let item = item.trim();
3862 if !item.is_empty() {
3863 md.push_str("- ");
3864 md.push_str(item);
3865 md.push('\n');
3866 }
3867 }
3868 md.push('\n');
3869 }
3870 }
3871
3872 if let Some(partial) = partial_output {
3873 let partial = partial.trim();
3874 if !partial.is_empty() {
3875 md.push_str("## Partial Output\n");
3876 md.push_str("```\n");
3877 md.push_str(partial);
3878 md.push_str("\n```\n");
3879 }
3880 }
3881
3882 std::fs::write(&path, md).context("write research report")?;
3883 Ok(Some(path))
3884}
3885
3886fn slash_menu_lines() -> Vec<String> {
3887 use style::*;
3888
3889 let mut lines = Vec::new();
3890 lines.push(format!("{}{}Slash Commands{} {}(↑/↓ to cycle){}", BOLD, CYAN, RESET, GRAY, RESET));
3891 lines.push(String::new());
3892 for cmd in SLASH_COMMANDS {
3893 lines.push(format!(
3894 "{}{:<14}{} {}{}{}",
3895 WHITE, cmd.name, RESET,
3896 GRAY, cmd.desc, RESET
3897 ));
3898 }
3899 lines
3900}
3901
3902fn format_duration(d: Duration) -> String {
3903 let secs = d.as_secs();
3904 if secs < 60 {
3905 format!("{}s", secs)
3906 } else if secs < 3600 {
3907 format!("{}m {}s", secs / 60, secs % 60)
3908 } else {
3909 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
3910 }
3911}
3912
3913async fn cmd_tui() -> Result<()> {
3914 let paths = Paths::discover().context("discover paths")?;
3915 let mut cfg = config::load_or_create(&paths).context("load/create config")?;
3916 ensure_tui_default_model(&mut cfg.chat);
3917 let adapter = eli_adapters::build_from_chat_config(&cfg.chat).context("build adapter")?;
3918 let adapter: Arc<dyn LlmAdapter> = Arc::from(adapter);
3919
3920 let cwd = std::env::current_dir().context("get cwd")?;
3921 let project_root = cfg
3922 .chat
3923 .resolved_project_root(&cwd)
3924 .map_err(|e| anyhow::anyhow!(e))
3925 .context("resolve project root")?;
3926
3927 let diff_engine = DiffEngine::new(project_root.clone()).context("init diff engine")?;
3928 let command_runner = CommandRunner::new(
3929 cfg.chat.timeout_secs,
3930 cfg.chat.max_cmds,
3931 cfg.chat.parallel_commands,
3932 project_root,
3933 );
3934 let store = SessionStore::new(&paths);
3935 let session_id = uuid::Uuid::new_v4().to_string();
3936
3937 eli_tui::run(cfg.chat, adapter, diff_engine, command_runner, store, session_id)
3938 .await
3939 .context("run tui")?;
3940 Ok(())
3941}
3942
3943fn apply_overrides(cfg: &mut ConfigFile, provider: Option<String>, model: Option<String>) -> Result<()> {
3944 if let Some(provider) = provider {
3945 cfg.chat.provider = provider
3946 .parse::<ProviderKind>()
3947 .map_err(|e| anyhow::anyhow!(e))
3948 .context("parse provider")?;
3949 }
3950 if let Some(model) = model {
3951 cfg.chat.model = model;
3952 }
3953 Ok(())
3954}
3955
3956use base64::Engine;
3957
3958fn ensure_tui_default_model(chat: &mut eli_core::config::ChatConfig) {
3959 let model = chat.model.trim();
3960 if model.is_empty() || model.eq_ignore_ascii_case("test") {
3961 chat.model = config::DEFAULT_OPENROUTER_MODEL.to_string();
3962 }
3963}
3964
3965fn debug_print_request(req: &ChatRequest) {
3966 println!("\n=== REQUEST ===");
3967 match serde_json::to_string_pretty(req) {
3968 Ok(json) => println!("{json}"),
3969 Err(err) => println!("(failed to serialize request: {err})"),
3970 }
3971 println!("\n=== END REQUEST ===");
3972}
3973
3974fn process_input_for_images(input: &str) -> (String, Vec<String>) {
3975 let mut clean_words = Vec::new();
3976 let mut images = Vec::new();
3977
3978 for word in input.split_whitespace() {
3979 let path = Path::new(word);
3980 if path.exists() && path.is_file() {
3981 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
3982 let ext = ext.to_lowercase();
3983 if matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "webp" | "gif") {
3984 if let Ok(bytes) = std::fs::read(path) {
3985 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
3986 let mime = match ext.as_str() {
3987 "png" => "image/png",
3988 "jpg" | "jpeg" => "image/jpeg",
3989 "webp" => "image/webp",
3990 "gif" => "image/gif",
3991 _ => "application/octet-stream",
3992 };
3993 images.push(format!("data:{};base64,{}", mime, b64));
3994 continue; }
3996 }
3997 }
3998 }
3999 clean_words.push(word);
4000 }
4001
4002 (clean_words.join(" "), images)
4003}
4004
4005async fn run_agent_steps(
4006 chat: &eli_core::config::ChatConfig,
4007 adapter: Arc<dyn LlmAdapter>,
4008 diff_engine: &DiffEngine,
4009 command_runner: &CommandRunner,
4010 store: &SessionStore,
4011 data_dir: &Path,
4012 session_id: &str,
4013 project_root: &Path,
4014 memory: &mut eli_core::memory::Memory,
4015 undo_stack: &mut Vec<Vec<DiffResult>>,
4016 state: &mut SessionState,
4017 profile: AgentProfile,
4018 initial_user_message: String,
4019 initial_images: Vec<String>,
4020) -> Result<()> {
4021 let trajectory_logger = eli_core::trajectory::TrajectoryLogger::new(data_dir.to_path_buf());
4022
4023 let max_iters = if chat.auto { chat.max_auto.max(1) } else { 1 };
4024 let task_start = Instant::now();
4025 let debug = matches!(state.display_mode, DisplayMode::Debug) || matches!(chat.display_mode, DisplayMode::Debug);
4026 let brief = matches!(state.display_mode, DisplayMode::Standard) && !matches!(chat.display_mode, DisplayMode::Debug);
4027 let mut footer: Option<FooterUi> = None;
4028 let mut spinner_idx = 0usize;
4029 let mut last_anim = Instant::now();
4030 let synthesis_title = format_synthesis_title(&initial_user_message);
4031 let mut task_had_actions = false;
4032 let mut task_insights: Vec<String> = Vec::new();
4033 let mut saw_finance_timeseries = false;
4034 let mut saw_finance_snapshot = false;
4035 let mut plan_confirmed = !matches!(state.auto_mode, AutoMode::Plan);
4036 let mut current_message = initial_user_message;
4037 let mut current_images = initial_images;
4038 let root_prompt = current_message.clone();
4039
4040 for step in 1..=max_iters {
4041 let step_start = Instant::now();
4042 state.step_count += 1;
4043 let mut step_observation: Option<String> = None;
4044
4045 let skip_keep_working = step > 1 && current_message == "KEEP WORKING" && memory.last_role() == Some(eli_core::types::Role::Tool);
4048
4049 if !skip_keep_working {
4050 store
4051 .append(
4052 session_id,
4053 &SessionEvent {
4054 ts: chrono::Utc::now(),
4055 kind: EventKind::UserMessage {
4056 content: current_message.clone(),
4057 },
4058 },
4059 )
4060 .await
4061 .ok();
4062
4063 if !current_images.is_empty() {
4064 memory.push(ChatMessage::user_with_images(current_message.clone(), current_images.clone()));
4065 if !brief {
4066 println!("(attached {} images)", current_images.len());
4067 }
4068 current_images.clear();
4070 } else {
4071 memory.push(ChatMessage::user(current_message.clone()));
4072 }
4073 }
4074
4075 if let Ok(Some(compaction)) = maybe_compact_memory(adapter.clone(), chat, memory).await {
4076 let note = format!(
4077 "memory_compaction: dropped {} messages\n{}",
4078 compaction.dropped,
4079 compaction.summary
4080 );
4081 let brain_entry = format!(
4082 "\n### {} (session {})\n{}\n",
4083 chrono::Utc::now().to_rfc3339(),
4084 session_id,
4085 note
4086 );
4087 if let Err(e) = append_eli_brain(project_root, &brain_entry) {
4088 warn!("eli brain: failed to persist compaction (ignored): {e}");
4089 }
4090 store
4091 .append(
4092 session_id,
4093 &SessionEvent {
4094 ts: chrono::Utc::now(),
4095 kind: EventKind::Note { content: note.clone() },
4096 },
4097 )
4098 .await
4099 .ok();
4100 if !brief {
4101 println!("memory: compacted ({} msgs)", compaction.dropped);
4102 }
4103 }
4104
4105 let mut messages = memory.context();
4106 if let Ok(Some(ctx)) = read_eli_brain_context(project_root, 2_000, 6_000) {
4107 insert_system_context_before_conversation(&mut messages, ChatMessage::system(ctx));
4108 }
4109 let trajectory_input = messages.clone();
4110
4111 let req = ChatRequest {
4112 model: chat.model.clone(),
4113 messages,
4114 temperature: chat.temperature,
4115 max_tokens: chat.max_tokens,
4116 response_format: None,
4117 stream: true,
4118 };
4119
4120 if debug {
4121 debug_print_request(&req);
4122 }
4123
4124 use std::io::Write;
4125 let mut out = String::new();
4126 let mut interrupted = false;
4127 let mut interrupted_by_esc = false;
4128
4129 let connect_start = Instant::now();
4130 if brief {
4131 if footer.is_none() {
4132 footer = Some(FooterUi::enable());
4133 }
4134 render_footer(
4135 &mut footer,
4136 "connecting",
4137 spinner_idx,
4138 connect_start.elapsed(),
4139 state,
4140 None,
4141 );
4142 } else {
4143 print!("{}eli[{}]>{} connecting...", style::CYAN, step, style::RESET);
4144 std::io::stdout().flush().ok();
4145 }
4146
4147 let stream_opt = if brief {
4148 let mut fut = Box::pin(adapter.chat_stream(req));
4149 loop {
4150 let changed = drain_run_key_events(state, &mut interrupted, &mut interrupted_by_esc);
4151 if last_anim.elapsed() > Duration::from_millis(120) || changed {
4152 if last_anim.elapsed() > Duration::from_millis(120) {
4153 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
4154 last_anim = Instant::now();
4155 }
4156 render_footer(
4157 &mut footer,
4158 "connecting",
4159 spinner_idx,
4160 connect_start.elapsed(),
4161 state,
4162 None,
4163 );
4164 }
4165 if interrupted {
4166 break None;
4167 }
4168 tokio::select! {
4169 res = &mut fut => break Some(res.context("chat_stream")?),
4170 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
4171 }
4172 }
4173 } else {
4174 Some(adapter.chat_stream(req).await.context("chat_stream")?)
4175 };
4176
4177 if let Some(mut stream) = stream_opt {
4178 let thinking_start = Instant::now();
4179 if brief {
4180 render_footer(
4181 &mut footer,
4182 "thinking",
4183 spinner_idx,
4184 thinking_start.elapsed(),
4185 state,
4186 None,
4187 );
4188 }
4189
4190 loop {
4191 tokio::select! {
4192 maybe_ev = stream.next() => {
4193 let Some(ev) = maybe_ev else { break; };
4194 match ev.context("stream event")? {
4195 eli_core::types::ChatStreamEvent::Delta(delta) => {
4196 out.push_str(&delta);
4197 }
4198 eli_core::types::ChatStreamEvent::Usage(usage) => {
4199 state.last_usage = Some(usage.clone());
4200 state.total_usage.prompt_tokens += usage.prompt_tokens;
4201 state.total_usage.completion_tokens += usage.completion_tokens;
4202 state.total_usage.total_tokens += usage.total_tokens;
4203 }
4204 eli_core::types::ChatStreamEvent::Done => break,
4205 }
4206 }
4207 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
4208 }
4209
4210 let changed = drain_run_key_events(state, &mut interrupted, &mut interrupted_by_esc);
4211 if interrupted {
4212 break;
4213 }
4214
4215 if last_anim.elapsed() > Duration::from_millis(120) || changed {
4216 if last_anim.elapsed() > Duration::from_millis(120) {
4217 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
4218 last_anim = Instant::now();
4219 }
4220 render_footer(
4221 &mut footer,
4222 "thinking",
4223 spinner_idx,
4224 thinking_start.elapsed(),
4225 state,
4226 None,
4227 );
4228 }
4229 }
4230 }
4231
4232 if brief && interrupted_by_esc {
4233 let mut armed = false;
4234 let mut deadline = Instant::now() + Duration::from_secs(2);
4235 while Instant::now() < deadline {
4236 if !ct_event::poll(Duration::from_millis(60)).unwrap_or(false) {
4237 continue;
4238 }
4239 let Ok(CtEvent::Key(key)) = ct_event::read() else {
4240 continue;
4241 };
4242 if key.kind != KeyEventKind::Press {
4243 continue;
4244 }
4245
4246 match key.code {
4247 CtKeyCode::Esc => {
4248 if !armed {
4249 armed = true;
4250 print!(
4251 "\r\x1b[K {}!{} press {}Esc{} again to clear input",
4252 style::YELLOW,
4253 style::RESET,
4254 style::WHITE,
4255 style::RESET
4256 );
4257 std::io::stdout().flush().ok();
4258 deadline = Instant::now() + Duration::from_secs(2);
4259 } else {
4260 state.input_buffer.clear();
4261 state.cursor_pos = 0;
4262 break;
4263 }
4264 }
4265 CtKeyCode::Char(c) => {
4266 state.input_buffer.insert(state.cursor_pos, c);
4267 state.cursor_pos += 1;
4268 break;
4269 }
4270 CtKeyCode::Backspace => {
4271 if state.cursor_pos > 0 {
4272 state.cursor_pos -= 1;
4273 state.input_buffer.remove(state.cursor_pos);
4274 }
4275 break;
4276 }
4277 _ => break,
4278 }
4279 }
4280 print!("\r\x1b[K");
4281 std::io::stdout().flush().ok();
4282 }
4283
4284 if interrupted {
4285 println!("(interrupted)");
4286 if profile == AgentProfile::Research {
4287 match write_research_report_md(
4288 project_root,
4289 session_id,
4290 chat,
4291 &root_prompt,
4292 None,
4293 "interrupted",
4294 Some(&out),
4295 ) {
4296 Ok(Some(path)) => {
4297 let rel = path.strip_prefix(project_root).unwrap_or(&path);
4298 if brief {
4299 println!(" saved: {}", rel.display());
4300 } else {
4301 println!("(saved: {})", rel.display());
4302 }
4303
4304 let note = format!(
4305 "research_report_saved: {}\nstatus: interrupted\ntitle: {}",
4306 rel.display(),
4307 truncate(&root_prompt, 120)
4308 );
4309 memory.push(ChatMessage::tool(note.clone(), "eli.research"));
4310 store
4311 .append(
4312 session_id,
4313 &SessionEvent {
4314 ts: chrono::Utc::now(),
4315 kind: EventKind::Note { content: note },
4316 },
4317 )
4318 .await
4319 .ok();
4320
4321 state.record_research_report(
4322 ResearchArtifact {
4323 rel_path: rel.display().to_string(),
4324 title: root_prompt.clone(),
4325 status: "interrupted".to_string(),
4326 created_utc: chrono::Utc::now().to_rfc3339(),
4327 answer_hint: None,
4328 },
4329 24,
4330 );
4331
4332 let brain_entry = format!(
4333 "\n### {} (session {})\n- Research saved: {} (interrupted)\n",
4334 chrono::Utc::now().to_rfc3339(),
4335 session_id,
4336 rel.display()
4337 );
4338 if let Err(e) = append_eli_brain(project_root, &brain_entry) {
4339 warn!("eli brain: failed to persist research pointer (ignored): {e}");
4340 }
4341 }
4342 Ok(None) => {}
4343 Err(e) => warn!("failed to write interrupted research report (ignored): {e}"),
4344 }
4345 }
4346 break;
4347 }
4348
4349 if out.trim().is_empty() {
4350 warn!("empty assistant message");
4351 break;
4352 }
4353
4354 if debug {
4355 println!("\n=== RAW MODEL OUTPUT ===");
4356 print!("{}", out);
4357 if !out.ends_with('\n') {
4358 println!();
4359 }
4360 println!("=== END RAW MODEL OUTPUT ===");
4361 }
4362
4363 memory.push(ChatMessage::assistant(out.clone()));
4364 store
4365 .append(
4366 session_id,
4367 &SessionEvent {
4368 ts: chrono::Utc::now(),
4369 kind: EventKind::AssistantMessage { content: out.clone() },
4370 },
4371 )
4372 .await
4373 .ok();
4374
4375 let model = match contract::validate_model_response(&out) {
4376 Ok(m) => m,
4377 Err(e) => {
4378 println!("eli: invalid response ({})", e);
4379 if !brief {
4380 println!("{}", out);
4381 }
4382 break;
4383 }
4384 };
4385
4386 let step_elapsed = step_start.elapsed();
4388
4389 if brief {
4391 if step == 1 {
4392 print_history_line(String::new());
4394 }
4395 print_step_summary_brief(step, step_elapsed, &model);
4396 render_footer(&mut footer, "ready", spinner_idx, Duration::ZERO, state, None);
4397 } else {
4398 print_step_summary(step, &model);
4399 }
4400
4401 let mut read_mode = matches!(chat.mode, RunMode::Read);
4402 let mut approvals_ask_commands = matches!(chat.resolved_command_approvals(), ApprovalMode::Ask);
4403 let mut approvals_ask_diffs = matches!(chat.resolved_diff_approvals(), ApprovalMode::Ask);
4404 let (plan_mode, plan_approvals) = parse_plan_controls(&model.plan);
4405 if matches!(plan_mode, Some(RunMode::Read)) {
4406 read_mode = true;
4407 }
4408 if matches!(plan_approvals, Some(ApprovalMode::Ask)) {
4409 approvals_ask_commands = true;
4410 approvals_ask_diffs = true;
4411 }
4412
4413 let wants_user_input = model
4414 .ask_user
4415 .as_deref()
4416 .map(|s| !s.trim().is_empty())
4417 .unwrap_or(false);
4418
4419 let has_actions =
4420 !model.commands.is_empty() || !model.diffs.is_empty() || !model.subagents.is_empty();
4421
4422 if debug {
4423 println!("\n=== TOOL CALL ATTEMPTED ===");
4424 if model.commands.is_empty() && model.diffs.is_empty() && model.subagents.is_empty() && model.screen.is_empty() {
4425 println!("(none)");
4426 } else {
4427 if !model.commands.is_empty() {
4428 println!("commands:");
4429 for cmd in &model.commands {
4430 println!(" $ {}", cmd);
4431 }
4432 }
4433 if !model.diffs.is_empty() {
4434 println!("diffs: {}", model.diffs.len());
4435 for diff in &model.diffs {
4436 println!(" {:?} {}", diff.op, diff.path);
4437 }
4438 }
4439 if !model.subagents.is_empty() {
4440 println!("subagents: {}", model.subagents.len());
4441 for agent in &model.subagents {
4442 println!(" {} (model: {})", agent.name, agent.model.as_deref().unwrap_or("default"));
4443 }
4444 }
4445 if !model.screen.is_empty() {
4446 println!("screen actions: {}", model.screen.len());
4447 }
4448 }
4449 }
4450
4451 if matches!(state.auto_mode, AutoMode::Plan)
4452 && !plan_confirmed
4453 && !wants_user_input
4454 && !model.plan.trim().is_empty()
4455 && (has_actions || matches!(model.status, StepStatus::KeepWorking))
4456 {
4457 if brief {
4458 footer.take();
4459 }
4460
4461 println!(
4462 "\n{}[PLAN]{} \n{}\n",
4463 style::BLUE,
4464 style::RESET,
4465 model.plan.trim_end()
4466 );
4467
4468 use std::io::Write;
4469 print!(
4470 "{}?{} Confirm plan (Enter = proceed, type = critique): ",
4471 style::YELLOW,
4472 style::RESET
4473 );
4474 std::io::stdout().flush().ok();
4475
4476 let mut input = String::new();
4477 std::io::stdin()
4478 .read_line(&mut input)
4479 .context("read plan confirmation input")?;
4480 let critique = input.trim();
4481
4482 if !critique.is_empty() {
4483 current_message = critique.to_string();
4484 current_images.clear();
4485 continue;
4486 }
4487
4488 plan_confirmed = true;
4489
4490 if !has_actions {
4491 current_message = "Plan approved. Proceed with execution.".to_string();
4492 continue;
4493 }
4494 }
4495
4496 let mut diff_results: Vec<DiffResult> = Vec::new();
4497 let mut command_results: Vec<CommandResult> = Vec::new();
4498 if !wants_user_input {
4499 if !model.diffs.is_empty() {
4500 if read_mode {
4501 for diff in &model.diffs {
4503 let is_create = matches!(diff.op, contract::DiffOp::Create);
4504 let res = diff_engine.apply_diff(diff, !is_create);
4505 diff_results.push(res);
4506 }
4507 print_diff_results(&diff_results, true, brief);
4508 let actual_changes: Vec<_> = diff_results
4509 .iter()
4510 .filter(|r| !r.preview && r.success)
4511 .cloned()
4512 .collect();
4513 if !actual_changes.is_empty() {
4514 undo_stack.push(actual_changes);
4515 }
4516 } else {
4517 let apply = if approvals_ask_diffs {
4518 if brief {
4519 footer.take();
4520 }
4521 let ans = confirm("Apply diffs?")?;
4522 ans
4523 } else {
4524 true
4525 };
4526 diff_results = diff_engine.apply_diffs(&model.diffs, !apply);
4527 print_diff_results(&diff_results, !apply, brief);
4528 if apply {
4529 undo_stack.push(diff_results.clone());
4530 }
4531 }
4532 }
4533
4534 if !model.commands.is_empty() {
4535 if read_mode {
4536 let parallelism = if model.commands_parallel {
4538 chat.resolved_parallel_commands()
4539 } else {
4540 1
4541 };
4542 if brief {
4543 let exec_start = Instant::now();
4544 render_footer(
4545 &mut footer,
4546 "exec",
4547 spinner_idx,
4548 exec_start.elapsed(),
4549 state,
4550 None,
4551 );
4552
4553 let mut fut = Box::pin(run_commands_with_policy(
4554 profile,
4555 command_runner,
4556 &model.commands,
4557 parallelism,
4558 ));
4559 loop {
4560 tokio::select! {
4561 res = &mut fut => {
4562 command_results = res;
4563 break;
4564 }
4565 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
4566 }
4567 let changed = drain_run_key_events_queue_only(state);
4568 if last_anim.elapsed() > Duration::from_millis(120) || changed {
4569 if last_anim.elapsed() > Duration::from_millis(120) {
4570 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
4571 last_anim = Instant::now();
4572 }
4573 render_footer(
4574 &mut footer,
4575 "exec",
4576 spinner_idx,
4577 exec_start.elapsed(),
4578 state,
4579 None,
4580 );
4581 }
4582 }
4583 } else {
4584 command_results = run_commands_with_policy(
4585 profile,
4586 command_runner,
4587 &model.commands,
4588 parallelism,
4589 )
4590 .await;
4591 }
4592 } else {
4593 let run = if approvals_ask_commands {
4594 if brief {
4595 footer.take();
4596 }
4597 let ans = confirm("Run commands?")?;
4598 ans
4599 } else {
4600 true
4601 };
4602 if run {
4603 let parallelism = if model.commands_parallel {
4604 chat.resolved_parallel_commands()
4605 } else {
4606 1
4607 };
4608 if brief {
4609 let exec_start = Instant::now();
4610 render_footer(
4611 &mut footer,
4612 "exec",
4613 spinner_idx,
4614 exec_start.elapsed(),
4615 state,
4616 None,
4617 );
4618
4619 let mut fut = Box::pin(run_commands_with_policy(
4620 profile,
4621 command_runner,
4622 &model.commands,
4623 parallelism,
4624 ));
4625 loop {
4626 tokio::select! {
4627 res = &mut fut => {
4628 command_results = res;
4629 break;
4630 }
4631 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
4632 }
4633 let changed = drain_run_key_events_queue_only(state);
4634 if last_anim.elapsed() > Duration::from_millis(120) || changed {
4635 if last_anim.elapsed() > Duration::from_millis(120) {
4636 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
4637 last_anim = Instant::now();
4638 }
4639 render_footer(
4640 &mut footer,
4641 "exec",
4642 spinner_idx,
4643 exec_start.elapsed(),
4644 state,
4645 None,
4646 );
4647 }
4648 }
4649 } else {
4650 command_results = run_commands_with_policy(
4651 profile,
4652 command_runner,
4653 &model.commands,
4654 parallelism,
4655 )
4656 .await;
4657 }
4658 } else {
4659 command_results = model
4660 .commands
4661 .iter()
4662 .map(|cmd| CommandResult {
4663 command: cmd.clone(),
4664 returncode: -1,
4665 stdout: String::new(),
4666 stderr: "Skipped (approvals_cmds=ask)".to_string(),
4667 duration_ms: 0,
4668 allowed: false,
4669 deny_reason: Some("approvals_cmds=ask".to_string()),
4670 })
4671 .collect();
4672 }
4673 }
4674 }
4675
4676 if profile == AgentProfile::Research {
4677 if command_results.iter().any(|r| {
4678 r.allowed
4679 && r.returncode == 0
4680 && r.command.trim_start().starts_with("eli finance timeseries")
4681 }) {
4682 saw_finance_timeseries = true;
4683 }
4684 if command_results.iter().any(|r| {
4685 r.allowed
4686 && r.returncode == 0
4687 && r.command.trim_start().starts_with("eli finance snapshot")
4688 }) {
4689 saw_finance_snapshot = true;
4690 }
4691 }
4692
4693 if !command_results.is_empty() {
4694 command_results = augment_tool_errors(command_results);
4695 }
4696
4697 let insight = extract_insight(&command_results, &diff_results);
4698 if let Some(ref line) = insight {
4699 if task_insights.last().map(|s| s != line).unwrap_or(true) {
4700 if task_insights.len() < 6 {
4701 task_insights.push(line.to_string());
4702 }
4703 }
4704 }
4705
4706 if !command_results.is_empty() {
4707 if debug {
4708 print_tool_results_debug(&command_results);
4709 } else {
4710 print_command_results(
4711 &command_results,
4712 brief,
4713 matches!(state.display_mode, DisplayMode::Brain),
4714 );
4715 }
4716 if brief {
4717 render_footer(&mut footer, "ready", spinner_idx, Duration::ZERO, state, None);
4718 }
4719 }
4720
4721 if !model.screen.is_empty() && !read_mode && !brief {
4722 print_screen_results(&model.screen).await;
4723 }
4724
4725 let command_results_for_llm = shadow_large_tool_outputs(project_root, &command_results);
4726
4727 if !diff_results.is_empty() || !command_results.is_empty() || !model.screen.is_empty() {
4728 task_had_actions = true;
4729 let observation = build_observation(
4730 read_mode,
4731 approvals_ask_commands,
4732 approvals_ask_diffs,
4733 &diff_results,
4734 &command_results_for_llm,
4735 );
4736 if debug {
4737 println!("\n=== OBSERVATION INJECTED (eli) ===");
4738 print!("{}", observation);
4739 if !observation.ends_with('\n') {
4740 println!();
4741 }
4742 println!("=== END OBSERVATION INJECTED (eli) ===");
4743 }
4744 step_observation = Some(observation.clone());
4745 memory.push(ChatMessage::tool(observation.clone(), "eli"));
4746 store
4747 .append(
4748 session_id,
4749 &SessionEvent {
4750 ts: chrono::Utc::now(),
4751 kind: EventKind::Note { content: observation },
4752 },
4753 )
4754 .await
4755 .ok();
4756 }
4757 }
4758
4759 let subagent_results = if wants_user_input || model.subagents.is_empty() {
4760 Vec::new()
4761 } else if brief {
4762 let agents_start = Instant::now();
4763 render_footer(
4764 &mut footer,
4765 "agents",
4766 spinner_idx,
4767 agents_start.elapsed(),
4768 state,
4769 None,
4770 );
4771
4772 let mut fut = Box::pin(run_subagents(adapter.clone(), chat, memory, &model.subagents));
4773 let results = loop {
4774 tokio::select! {
4775 res = &mut fut => {
4776 break res;
4777 }
4778 _ = tokio::time::sleep(Duration::from_millis(50)) => {}
4779 }
4780 let changed = drain_run_key_events_queue_only(state);
4781 if last_anim.elapsed() > Duration::from_millis(120) || changed {
4782 if last_anim.elapsed() > Duration::from_millis(120) {
4783 spinner_idx = (spinner_idx + 1) % FOOTER_SPINNER.len();
4784 last_anim = Instant::now();
4785 }
4786 render_footer(
4787 &mut footer,
4788 "agents",
4789 spinner_idx,
4790 agents_start.elapsed(),
4791 state,
4792 None,
4793 );
4794 }
4795 };
4796 results
4797 } else {
4798 run_subagents(adapter.clone(), chat, memory, &model.subagents).await
4799 };
4800 if !subagent_results.is_empty() {
4801 task_had_actions = true;
4802 if !brief {
4803 print_subagent_results(&subagent_results);
4804 } else {
4805 println!(" subagents: {} completed", subagent_results.len());
4806 }
4807 if brief {
4808 render_footer(&mut footer, "ready", spinner_idx, Duration::ZERO, state, None);
4809 }
4810 let observation = build_subagent_observation(&subagent_results);
4811 if debug {
4812 println!("\n=== OBSERVATION INJECTED (eli.subagents) ===");
4813 print!("{}", observation);
4814 if !observation.ends_with('\n') {
4815 println!();
4816 }
4817 println!("=== END OBSERVATION INJECTED (eli.subagents) ===");
4818 }
4819 if let Some(ref mut existing) = step_observation {
4820 existing.push_str("\n");
4821 existing.push_str(&observation);
4822 } else {
4823 step_observation = Some(observation.clone());
4824 }
4825 memory.push(ChatMessage::tool(observation.clone(), "eli.subagents"));
4826 store
4827 .append(
4828 session_id,
4829 &SessionEvent {
4830 ts: chrono::Utc::now(),
4831 kind: EventKind::Note { content: observation },
4832 },
4833 )
4834 .await
4835 .ok();
4836 }
4837
4838 let _ = trajectory_logger.append(&eli_core::trajectory::TrajectoryStep {
4840 session_id: session_id.to_string(),
4841 step_index: step as usize,
4842 timestamp: chrono::Utc::now(),
4843 input_messages: trajectory_input,
4844 model_output_raw: out.clone(),
4845 observation: step_observation,
4846 usage: state.last_usage.clone(),
4847 }).await;
4848
4849 match model.status {
4850 StepStatus::Done => {
4851 let show_wrap_up = task_had_actions || step > 1;
4852
4853 let mut fallback = None;
4854 let synthesis = model
4855 .synthesis
4856 .as_ref()
4857 .filter(|s| synthesis_has_content(s))
4858 .or_else(|| {
4859 fallback = build_fallback_synthesis(&task_insights, model.notes.trim());
4860 fallback.as_ref()
4861 });
4862
4863 if !wants_user_input {
4864 if let Some(synthesis) = synthesis {
4865 if show_wrap_up
4867 || !synthesis.summary.is_empty()
4868 || !synthesis.next_steps.is_empty()
4869 {
4870 print_synthesis_box(&synthesis_title, synthesis);
4871 }
4872 }
4874 }
4876
4877 if profile == AgentProfile::Research {
4878 let status = if wants_user_input { "needs_user_input" } else { "done" };
4879 let partial = if synthesis.is_some() {
4880 None
4881 } else {
4882 Some(model.notes.as_str())
4883 };
4884
4885 match write_research_report_md(
4886 project_root,
4887 session_id,
4888 chat,
4889 &root_prompt,
4890 synthesis,
4891 status,
4892 partial,
4893 ) {
4894 Ok(Some(path)) => {
4895 let rel = path.strip_prefix(project_root).unwrap_or(&path);
4896 if brief {
4897 println!(" saved: {}", rel.display());
4898 } else {
4899 println!("(saved: {})", rel.display());
4900 }
4901
4902 let note = format!(
4903 "research_report_saved: {}\nstatus: {}\ntitle: {}",
4904 rel.display(),
4905 status,
4906 truncate(&root_prompt, 120)
4907 );
4908 memory.push(ChatMessage::tool(note.clone(), "eli.research"));
4909 store
4910 .append(
4911 session_id,
4912 &SessionEvent {
4913 ts: chrono::Utc::now(),
4914 kind: EventKind::Note { content: note },
4915 },
4916 )
4917 .await
4918 .ok();
4919
4920 state.record_research_report(
4921 ResearchArtifact {
4922 rel_path: rel.display().to_string(),
4923 title: root_prompt.clone(),
4924 status: status.to_string(),
4925 created_utc: chrono::Utc::now().to_rfc3339(),
4926 answer_hint: synthesis
4927 .map(|s| s.answer.clone())
4928 .filter(|s| !s.trim().is_empty()),
4929 },
4930 24,
4931 );
4932
4933 let brain_entry = format!(
4934 "\n### {} (session {})\n- Research saved: {} ({})\n",
4935 chrono::Utc::now().to_rfc3339(),
4936 session_id,
4937 rel.display(),
4938 status
4939 );
4940 if let Err(e) = append_eli_brain(project_root, &brain_entry) {
4941 warn!("eli brain: failed to persist research pointer (ignored): {e}");
4942 }
4943 }
4944 Ok(None) => {}
4945 Err(e) => warn!("failed to write research report (ignored): {e}"),
4946 }
4947 }
4948
4949 let task_elapsed = task_start.elapsed();
4951 state.total_work_time += task_elapsed;
4952 if brief && step > 1 {
4953 println!(
4954 "\n{}✓{} done in {} ({} steps)",
4955 style::GREEN, style::RESET,
4956 format_duration(task_elapsed),
4957 step
4958 );
4959 }
4960 break;
4961 }
4962 StepStatus::KeepWorking => {
4963 if step == max_iters {
4964 println!("(stopped: max autonomous steps reached)");
4965 if profile == AgentProfile::Research {
4966 let synthesis = model
4967 .synthesis
4968 .as_ref()
4969 .filter(|s| synthesis_has_content(s));
4970 match write_research_report_md(
4971 project_root,
4972 session_id,
4973 chat,
4974 &root_prompt,
4975 synthesis,
4976 "stopped_max_steps",
4977 Some(model.notes.as_str()),
4978 ) {
4979 Ok(Some(path)) => {
4980 let rel = path.strip_prefix(project_root).unwrap_or(&path);
4981 if brief {
4982 println!(" saved: {}", rel.display());
4983 } else {
4984 println!("(saved: {})", rel.display());
4985 }
4986
4987 let note = format!(
4988 "research_report_saved: {}\nstatus: stopped_max_steps\ntitle: {}",
4989 rel.display(),
4990 truncate(&root_prompt, 120)
4991 );
4992 memory.push(ChatMessage::tool(note.clone(), "eli.research"));
4993 store
4994 .append(
4995 session_id,
4996 &SessionEvent {
4997 ts: chrono::Utc::now(),
4998 kind: EventKind::Note { content: note },
4999 },
5000 )
5001 .await
5002 .ok();
5003
5004 state.record_research_report(
5005 ResearchArtifact {
5006 rel_path: rel.display().to_string(),
5007 title: root_prompt.clone(),
5008 status: "stopped_max_steps".to_string(),
5009 created_utc: chrono::Utc::now().to_rfc3339(),
5010 answer_hint: synthesis
5011 .map(|s| s.answer.clone())
5012 .filter(|s| !s.trim().is_empty()),
5013 },
5014 24,
5015 );
5016
5017 let brain_entry = format!(
5018 "\n### {} (session {})\n- Research saved: {} (stopped_max_steps)\n",
5019 chrono::Utc::now().to_rfc3339(),
5020 session_id,
5021 rel.display()
5022 );
5023 if let Err(e) = append_eli_brain(project_root, &brain_entry) {
5024 warn!("eli brain: failed to persist research pointer (ignored): {e}");
5025 }
5026 }
5027 Ok(None) => {}
5028 Err(e) => warn!("failed to write research report (ignored): {e}"),
5029 }
5030 }
5031 }
5032 }
5033 }
5034
5035 if !chat.auto {
5036 let task_elapsed = task_start.elapsed();
5037 state.total_work_time += task_elapsed;
5038 break;
5039 }
5040
5041 if let Some(ask) = model.ask_user {
5042 if !ask.trim().is_empty() {
5043 if brief {
5044 footer.take();
5045 }
5046 let (msg, imgs) = prompt_user(ask.trim())?;
5047 current_message = msg;
5048 current_images = imgs;
5049 continue;
5050 }
5051 }
5052
5053 current_message = "KEEP WORKING".to_string();
5054 }
5055
5056 if brief {
5057 footer.take();
5058 }
5059
5060 Ok(())
5061}
5062
5063fn print_banner(chat: &eli_core::config::ChatConfig, project_root: &Path, _state: &SessionState) {
5064 use style::*;
5065
5066 let model = truncate_middle(&chat.model, 60);
5067 let root = format_root_path(project_root);
5068 println!(
5070 r#"
5071{W1}{BOLD} ███████╗██╗ ██╗{RESET}
5072{W2}{BOLD} ██╔════╝██║ ██║{RESET} {WHITE}financial coding agent{RESET}
5073{W3}{BOLD} █████╗ ██║ ██║{RESET} {GRAY}v0.1.0{RESET}
5074{W4}{BOLD} ██╔══╝ ██║ ██║{RESET}
5075{W5}{BOLD} ███████╗███████╗██║{RESET}
5076{W6}{BOLD} ╚══════╝╚══════╝╚═╝{RESET}
5077"#,
5078 W1 = "\x1b[38;5;255m", W2 = "\x1b[38;5;252m", W3 = "\x1b[38;5;249m", W4 = "\x1b[38;5;246m", W5 = "\x1b[38;5;243m", W6 = "\x1b[38;5;240m", );
5085
5086 println!(
5087 "{}({} / {}){}",
5088 GRAY,
5089 chat.provider,
5090 model,
5091 RESET
5092 );
5093 println!("{}cwd{} {}", GRAY, RESET, root);
5094 println!("{}Auto mode. /help for commands.{}", DARK_GRAY, RESET);
5095 println!();
5096}
5097
5098fn print_step_summary(step: u32, model: &eli_core::contract::ModelResponse) {
5099 use style::*;
5100
5101 let mut lines = Vec::new();
5102 if !model.notes.trim().is_empty() {
5103 lines.push(format!(
5104 "{}eli[{}]{} {}",
5105 CYAN, step, RESET,
5106 model.notes.trim()
5107 ));
5108 }
5109
5110 let mut plan_lines = model.plan.lines();
5111 if let Some(first) = plan_lines.next() {
5112 if !first.trim().is_empty() {
5113 lines.push(format!("{}→{} plan: {}", PURPLE, RESET, first.trim()));
5114 }
5115 }
5116 if let Some(second) = plan_lines.next() {
5117 if !second.trim().is_empty() {
5118 lines.push(format!("{}→{} next: {}", BLUE, RESET, second.trim()));
5119 }
5120 }
5121
5122 if !model.focus.trim().is_empty() {
5123 lines.push(format!("{}◆{} focus: {}", YELLOW, RESET, model.focus.trim()));
5124 }
5125
5126 if !model.checklist.is_empty() {
5127 lines.push(format!("{}checklist:{}", GRAY, RESET));
5128 for item in model.checklist.iter().take(4) {
5129 if !item.trim().is_empty() {
5130 lines.push(format!(" {}•{} {}", GREEN, RESET, item.trim()));
5131 }
5132 }
5133 if model.checklist.len() > 4 {
5134 lines.push(format!(" {}... +{} more{}", DARK_GRAY, model.checklist.len() - 4, RESET));
5135 }
5136 }
5137
5138 let status = match model.status {
5139 StepStatus::KeepWorking => format!("{}● keep_working{}", YELLOW, RESET),
5140 StepStatus::Done => format!("{}✓ done{}", GREEN, RESET),
5141 };
5142 lines.push(format!("status: {}", status));
5143
5144 let out = format_indented_block(&lines);
5145 println!("{}", out);
5146}
5147
5148fn print_step_summary_brief(_step: u32, elapsed: Duration, model: &eli_core::contract::ModelResponse) {
5150 let _ = elapsed;
5151 match model.status {
5152 StepStatus::KeepWorking => {
5153 let focus = if model.focus.trim().is_empty() {
5155 model.notes.lines().next().unwrap_or("").trim()
5156 } else {
5157 model.focus.trim()
5158 };
5159 if focus.is_empty() {
5160 return;
5161 }
5162 print_history_line(format!(
5163 "→ {}",
5164 focus
5165 ));
5166 }
5167 StepStatus::Done => {
5168 let answer = model
5170 .synthesis
5171 .as_ref()
5172 .map(|s| s.answer.trim())
5173 .filter(|s| !s.is_empty())
5174 .unwrap_or_else(|| model.notes.trim());
5175 if answer.is_empty() { return; }
5176
5177 print_history_line(String::new());
5178 print_markdown(answer);
5179 }
5180 };
5181}
5182
5183fn extract_insight(command_results: &[CommandResult], diff_results: &[DiffResult]) -> Option<String> {
5184 for result in command_results {
5185 if let Some(line) = result.stdout.lines().find(|l| !l.trim().is_empty()) {
5186 return Some(truncate_line(line.trim(), 120));
5187 }
5188 }
5189
5190 if let Some(diff) = diff_results.first() {
5191 let detail = format!("{} {}", diff.op, diff.path);
5192 return Some(truncate_line(&detail, 120));
5193 }
5194
5195 None
5196}
5197
5198fn build_command_digest(result: &CommandResult) -> String {
5199 let stdout = result.stdout.trim();
5200 let stderr = result.stderr.trim();
5201 let stdout_bytes = result.stdout.as_bytes().len();
5202 let stderr_bytes = result.stderr.as_bytes().len();
5203
5204 if result.returncode != 0 {
5205 return format!(
5206 "returncode={} stdout_bytes={} stderr_bytes={}",
5207 result.returncode, stdout_bytes, stderr_bytes
5208 );
5209 }
5210
5211 if stdout.is_empty() {
5212 return format!(
5213 "returncode={} stdout_bytes={} stderr_bytes={}",
5214 result.returncode, stdout_bytes, stderr_bytes
5215 );
5216 }
5217
5218 if stdout.starts_with("[OUTPUT SUPPRESSED]") {
5219 let mut parts: Vec<String> = Vec::new();
5220 if let Some(saved_to) = stdout.split("saved_to=").nth(1).and_then(|s| s.split_whitespace().next()) {
5221 parts.push(format!("saved_to={saved_to}"));
5222 }
5223 if let Some(bytes) = stdout.split('(').nth(1).and_then(|s| s.split(" bytes").next()) {
5224 if bytes.chars().all(|c| c.is_ascii_digit()) {
5225 parts.push(format!("bytes={bytes}"));
5226 }
5227 }
5228 if let Some(points) = stdout.split("Data points: ").nth(1).and_then(|s| s.split('.').next()) {
5229 let points = points.trim();
5230 if !points.is_empty() && points.chars().all(|c| c.is_ascii_digit()) {
5231 parts.push(format!("data_points={points}"));
5232 }
5233 }
5234 if parts.is_empty() {
5235 parts.push(format!("stdout_bytes={stdout_bytes}"));
5236 }
5237 return parts.join(" ");
5238 }
5239
5240 let looks_like_json = stdout.starts_with('{') || stdout.starts_with('[');
5241 if looks_like_json {
5242 if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
5243 return digest_from_json(&value, stdout_bytes);
5244 }
5245 }
5246
5247 let lines = stdout.lines().count();
5248 format!("stdout_bytes={} lines={}", stdout_bytes, lines)
5249}
5250
5251fn digest_from_json(value: &serde_json::Value, bytes: usize) -> String {
5252 let mut parts: Vec<String> = Vec::new();
5253 parts.push(format!("bytes={bytes}"));
5254
5255 match value {
5256 serde_json::Value::Array(items) => {
5257 parts.push(format!("items={}", items.len()));
5258 }
5259 serde_json::Value::Object(map) => {
5260 let mut array_parts: Vec<String> = Vec::new();
5261 for (key, val) in map.iter() {
5262 if let serde_json::Value::Array(items) = val {
5263 array_parts.push(format!("{key}={}", items.len()));
5264 }
5265 }
5266 if !array_parts.is_empty() {
5267 array_parts.truncate(4);
5268 parts.extend(array_parts);
5269 } else {
5270 parts.push(format!("keys={}", map.len()));
5271 }
5272 if let Some(ts) = map
5273 .get("generated_at")
5274 .and_then(|v| v.as_str())
5275 .filter(|v| !v.is_empty())
5276 {
5277 parts.push(format!("generated_at={ts}"));
5278 } else if let Some(ts) = map
5279 .get("fetched_at")
5280 .and_then(|v| v.as_str())
5281 .filter(|v| !v.is_empty())
5282 {
5283 parts.push(format!("fetched_at={ts}"));
5284 }
5285 }
5286 _ => {}
5287 }
5288
5289 parts.join(" ")
5290}
5291
5292fn synthesis_has_content(synthesis: &eli_core::contract::Synthesis) -> bool {
5293 !synthesis.summary.is_empty()
5294 || !synthesis.next_steps.is_empty()
5295 || !synthesis.answer.trim().is_empty()
5296}
5297
5298fn format_synthesis_title(_user_message: &str) -> String {
5299 String::new()
5300}
5301
5302fn print_markdown(text: &str) {
5303 let skin = MadSkin::default();
5304 skin.print_text(text);
5305}
5306
5307fn print_synthesis_box(title: &str, synthesis: &eli_core::contract::Synthesis) {
5308 use style::*;
5309
5310 let mut lines = Vec::new();
5311 if !title.trim().is_empty() {
5313 lines.push(format!("{}{}{}", GRAY, title, RESET));
5314 }
5315
5316 let mut seen = std::collections::HashSet::new();
5317 let summary: Vec<String> = synthesis
5318 .summary
5319 .iter()
5320 .map(|s| s.trim())
5321 .filter(|s| !s.is_empty())
5322 .filter(|s| seen.insert(s.to_string()))
5323 .take(6)
5324 .map(|s| format!("{}•{} {}", GREEN, RESET, s))
5325 .collect();
5326 if !summary.is_empty() {
5327 if !lines.is_empty() { lines.push(String::new()); }
5328 lines.extend(summary);
5329 }
5330
5331 if !synthesis.answer.trim().is_empty() {
5332 if !lines.is_empty() { lines.push(String::new()); }
5333
5334 let answer = synthesis.answer.trim();
5335 lines.push(format!("{}◆{} {}", CYAN, RESET, answer));
5347 }
5348
5349 let next_steps: Vec<String> = synthesis
5350 .next_steps
5351 .iter()
5352 .map(|s| s.trim())
5353 .filter(|s| !s.is_empty())
5354 .take(3)
5355 .map(|s| s.to_string())
5356 .collect();
5357 if !next_steps.is_empty() {
5358 if !lines.is_empty() { lines.push(String::new()); }
5359 lines.push(format!("{}next steps:{}", PURPLE, RESET));
5360 for (idx, step) in next_steps.iter().enumerate() {
5361 lines.push(format!("{}{}. {}{}", BLUE, idx + 1, RESET, step));
5362 }
5363 }
5364
5365 if lines.len() > 1 {
5366 let out = format_indented_block(&lines);
5367 println!("{}", out);
5368 }
5369}
5370
5371fn build_fallback_synthesis(
5372 insights: &[String],
5373 answer: &str,
5374) -> Option<eli_core::contract::Synthesis> {
5375 let summary: Vec<String> = insights
5376 .iter()
5377 .map(|s| s.trim())
5378 .filter(|s| !s.is_empty())
5379 .take(5)
5380 .map(|s| s.to_string())
5381 .collect();
5382 let answer = answer.trim();
5383 if summary.is_empty() && answer.is_empty() {
5384 return None;
5385 }
5386 Some(eli_core::contract::Synthesis {
5387 summary,
5388 answer: answer.to_string(),
5389 next_steps: Vec::new(),
5390 })
5391}
5392
5393fn print_subagent_results(results: &[SubagentResult]) {
5394 use style::*;
5395
5396 if results.is_empty() {
5397 return;
5398 }
5399 let mut lines = Vec::new();
5400 lines.push(format!("{}{}subagents{}", BOLD, PURPLE, RESET));
5401 for result in results {
5402 if let Some(err) = &result.error {
5403 lines.push(format!("{}✗{} {}: {}error{} {}", RED, RESET, result.name, RED, RESET, err));
5404 continue;
5405 }
5406 if result.output.trim().is_empty() {
5407 lines.push(format!("{}✓{} {}: {}(no output){}", GREEN, RESET, result.name, GRAY, RESET));
5408 continue;
5409 }
5410 lines.push(format!("{}✓{} {}:{}", GREEN, RESET, result.name, RESET));
5411 for line in result.output.lines().take(6) {
5412 if !line.trim().is_empty() {
5413 lines.push(format!(" {}{}{}", GRAY, line.trim(), RESET));
5414 }
5415 }
5416 }
5417 let out = format_indented_block(&lines);
5418 println!("{}", out);
5419}
5420
5421fn build_subagent_observation(results: &[SubagentResult]) -> String {
5422 let mut out = String::from("subagents:\n");
5423 for result in results {
5424 out.push_str(&format!("- {}\n", result.name));
5425 if let Some(err) = &result.error {
5426 out.push_str(&format!(" error: {err}\n"));
5427 continue;
5428 }
5429 if result.output.trim().is_empty() {
5430 out.push_str(" (no output)\n");
5431 continue;
5432 }
5433 for line in result.output.lines() {
5434 if line.trim().is_empty() {
5435 continue;
5436 }
5437 out.push_str(&format!(" {line}\n", line = line.trim()));
5438 }
5439 }
5440 out
5441}
5442
5443#[allow(dead_code)]
5448mod style {
5449 pub const TL: &str = "╭"; pub const TR: &str = "╮"; pub const BL: &str = "╰"; pub const BR: &str = "╯"; pub const H: &str = "─"; pub const V: &str = "│"; pub const RESET: &str = "\x1b[0m";
5459 pub const BOLD: &str = "\x1b[1m";
5460 pub const DIM: &str = "\x1b[2m";
5461
5462 pub const CYAN: &str = "\x1b[38;5;51m"; pub const BLUE: &str = "\x1b[38;5;39m"; pub const PURPLE: &str = "\x1b[38;5;141m"; pub const PINK: &str = "\x1b[38;5;213m"; pub const GREEN: &str = "\x1b[38;5;120m"; pub const YELLOW: &str = "\x1b[38;5;227m"; pub const ORANGE: &str = "\x1b[38;5;215m"; pub const RED: &str = "\x1b[38;5;203m"; pub const GRAY: &str = "\x1b[38;5;245m"; pub const DARK_GRAY: &str = "\x1b[38;5;238m"; pub const WHITE: &str = "\x1b[38;5;255m"; pub const SUCCESS: &str = "\x1b[38;5;120m"; pub const ERROR: &str = "\x1b[38;5;203m"; pub const WARN: &str = "\x1b[38;5;215m"; pub const INFO: &str = "\x1b[38;5;111m"; pub const MUTED: &str = "\x1b[38;5;245m"; }
5484
5485fn split_leading_spaces(s: &str) -> (String, &str) {
5486 let count = s.chars().take_while(|c| *c == ' ').count();
5487 let (indent, rest) = s.split_at(count);
5488 (indent.to_string(), rest)
5489}
5490
5491fn split_bullet_prefix(s: &str) -> (String, String) {
5492 let candidates = ["- ", "* ", "• ", "=> ", "→ "];
5493 for cand in candidates {
5494 if s.starts_with(cand) {
5495 return (cand.to_string(), s[cand.len()..].to_string());
5496 }
5497 }
5498 if let Some(pos) = s.find(". ") {
5499 if s[..pos].chars().all(|c| c.is_ascii_digit()) {
5500 return (s[..pos + 2].to_string(), s[pos + 2..].to_string());
5501 }
5502 }
5503 (String::new(), s.to_string())
5504}
5505
5506fn format_box_string(lines: &[String]) -> String {
5507 format_indented_block(lines)
5508}
5509
5510fn format_indented_block(lines: &[String]) -> String {
5511 if lines.is_empty() {
5512 return String::new();
5513 }
5514
5515 let (term_width, _term_height) = terminal_size();
5516 if term_width < 20 {
5517 return lines.join("\n");
5518 }
5519
5520 let term_width = term_width.min(140);
5521 let max_content_width = term_width.saturating_sub(1).max(1);
5522 let mut wrapped_lines = Vec::new();
5523 for line in lines {
5524 let clean = strip_ansi(line);
5525 if clean.trim().is_empty() {
5526 wrapped_lines.push(String::new());
5527 continue;
5528 }
5529
5530 let (indent, rest) = split_leading_spaces(&clean);
5531 let (prefix, content) = split_bullet_prefix(rest);
5532 let full = format!("{prefix}{content}");
5533 let subsequent_indent = if prefix.is_empty() {
5534 indent.clone()
5535 } else {
5536 format!("{}{}", indent, " ".repeat(prefix.width()))
5537 };
5538
5539 let options = WrapOptions::new(max_content_width)
5540 .break_words(true)
5541 .initial_indent(&indent)
5542 .subsequent_indent(&subsequent_indent);
5543 let wrapped = wrap(&full, &options);
5544 for line in wrapped {
5545 wrapped_lines.push(line.into_owned());
5546 }
5547 }
5548
5549 let mut out = wrapped_lines.join("\n");
5550 if !out.is_empty() {
5551 out.push('\n');
5552 }
5553 out
5554}
5555
5556fn tail_to_width(input: &str, max_width: usize) -> String {
5557 if max_width == 0 {
5558 return String::new();
5559 }
5560 let mut out = String::new();
5561 let mut width = 0usize;
5562 for ch in input.chars().rev() {
5563 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
5564 if width + w > max_width {
5565 break;
5566 }
5567 out.insert(0, ch);
5568 width += w;
5569 }
5570 out
5571}
5572
5573fn flush_buffer(out: &mut std::io::Stdout, buf: &Buffer, rect: Rect, top: u16) {
5574 let mut current_style = Style::default();
5575 for y in 0..rect.height {
5576 queue!(out, cursor::MoveTo(0, top + y)).ok();
5577 for x in 0..rect.width {
5578 let cell = buf.get(x, y);
5579 let cell_style = cell.style();
5580 if cell_style != current_style {
5581 apply_style(out, cell_style);
5582 current_style = cell_style;
5583 }
5584 queue!(out, crossterm::style::Print(cell.symbol())).ok();
5585 }
5586 queue!(out, SetAttribute(Attribute::Reset), ResetColor).ok();
5587 current_style = Style::default();
5588 }
5589}
5590
5591fn apply_style(out: &mut std::io::Stdout, style: Style) {
5592 queue!(out, SetAttribute(Attribute::Reset), ResetColor).ok();
5593 if let Some(fg) = style.fg {
5594 queue!(out, SetForegroundColor(map_color(fg))).ok();
5595 }
5596 if let Some(bg) = style.bg {
5597 queue!(out, SetBackgroundColor(map_color(bg))).ok();
5598 }
5599 let mods = style.add_modifier;
5600 if mods.contains(Modifier::BOLD) {
5601 queue!(out, SetAttribute(Attribute::Bold)).ok();
5602 }
5603 if mods.contains(Modifier::DIM) {
5604 queue!(out, SetAttribute(Attribute::Dim)).ok();
5605 }
5606 if mods.contains(Modifier::ITALIC) {
5607 queue!(out, SetAttribute(Attribute::Italic)).ok();
5608 }
5609 if mods.contains(Modifier::UNDERLINED) {
5610 queue!(out, SetAttribute(Attribute::Underlined)).ok();
5611 }
5612 if mods.contains(Modifier::REVERSED) {
5613 queue!(out, SetAttribute(Attribute::Reverse)).ok();
5614 }
5615 if mods.contains(Modifier::HIDDEN) {
5616 queue!(out, SetAttribute(Attribute::Hidden)).ok();
5617 }
5618 if mods.contains(Modifier::CROSSED_OUT) {
5619 queue!(out, SetAttribute(Attribute::CrossedOut)).ok();
5620 }
5621 if mods.contains(Modifier::SLOW_BLINK) {
5622 queue!(out, SetAttribute(Attribute::SlowBlink)).ok();
5623 }
5624 if mods.contains(Modifier::RAPID_BLINK) {
5625 queue!(out, SetAttribute(Attribute::RapidBlink)).ok();
5626 }
5627}
5628
5629fn map_color(color: Color) -> crossterm::style::Color {
5630 match color {
5631 Color::Reset => crossterm::style::Color::Reset,
5632 Color::Black => crossterm::style::Color::Black,
5633 Color::Red => crossterm::style::Color::DarkRed,
5634 Color::Green => crossterm::style::Color::DarkGreen,
5635 Color::Yellow => crossterm::style::Color::DarkYellow,
5636 Color::Blue => crossterm::style::Color::DarkBlue,
5637 Color::Magenta => crossterm::style::Color::DarkMagenta,
5638 Color::Cyan => crossterm::style::Color::DarkCyan,
5639 Color::Gray => crossterm::style::Color::Grey,
5640 Color::DarkGray => crossterm::style::Color::DarkGrey,
5641 Color::LightRed => crossterm::style::Color::Red,
5642 Color::LightGreen => crossterm::style::Color::Green,
5643 Color::LightYellow => crossterm::style::Color::Yellow,
5644 Color::LightBlue => crossterm::style::Color::Blue,
5645 Color::LightMagenta => crossterm::style::Color::Magenta,
5646 Color::LightCyan => crossterm::style::Color::Cyan,
5647 Color::White => crossterm::style::Color::White,
5648 Color::Indexed(idx) => crossterm::style::Color::AnsiValue(idx),
5649 Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b },
5650 }
5651}
5652
5653fn footer_title(
5654 phase: &str,
5655 spinner_idx: usize,
5656 queue_len: usize,
5657 elapsed: Duration,
5658 total_tokens: u32,
5659 mode: Option<PromptMode>,
5660) -> String {
5661 let spinner = FOOTER_SPINNER[spinner_idx % FOOTER_SPINNER.len()];
5662 let queue_indicator = if queue_len > 0 {
5663 format!(" [{}Q]", queue_len)
5664 } else {
5665 String::new()
5666 };
5667 let mode_chip = match mode {
5668 Some(PromptMode::Ask) => " [ASK]",
5669 Some(PromptMode::Plan) => " [PLAN]",
5670 Some(PromptMode::Auto) => " [AUTO]",
5671 None => "",
5672 };
5673 format!(
5674 "{spinner} {phase}{queue_indicator}{mode_chip} [{}s] {total_tokens} tokens",
5675 elapsed.as_secs()
5676 )
5677}
5678
5679fn render_footer(
5680 footer: &mut Option<FooterUi>,
5681 phase: &str,
5682 spinner_idx: usize,
5683 elapsed: Duration,
5684 state: &SessionState,
5685 mode: Option<PromptMode>,
5686) {
5687 if footer.is_none() {
5688 *footer = Some(FooterUi::enable());
5689 }
5690 if let Some(footer) = footer.as_mut() {
5691 let title = footer_title(
5692 phase,
5693 spinner_idx,
5694 state.queue_len(),
5695 elapsed,
5696 state.total_usage.total_tokens,
5697 mode,
5698 );
5699 footer.render(&title, &state.input_buffer, state.cursor_pos);
5700 }
5701}
5702
5703
5704fn drain_run_key_events(
5705 state: &mut SessionState,
5706 interrupted: &mut bool,
5707 interrupted_by_esc: &mut bool,
5708) -> bool {
5709 let mut changed = false;
5710 while ct_event::poll(Duration::from_millis(0)).unwrap_or(false) {
5711 let Ok(ev) = ct_event::read() else {
5712 continue;
5713 };
5714 match ev {
5715 CtEvent::Resize(_, _) => {
5716 changed = true;
5717 }
5718 CtEvent::Key(key) => {
5719 if key.kind != KeyEventKind::Press {
5720 continue;
5721 }
5722 match key.code {
5723 CtKeyCode::Char(c) => {
5724 state.input_buffer.insert(state.cursor_pos, c);
5725 state.cursor_pos += 1;
5726 changed = true;
5727 }
5728 CtKeyCode::Backspace => {
5729 if state.cursor_pos > 0 {
5730 state.cursor_pos -= 1;
5731 state.input_buffer.remove(state.cursor_pos);
5732 changed = true;
5733 }
5734 }
5735 CtKeyCode::Delete => {
5736 if state.cursor_pos < state.input_buffer.len() {
5737 state.input_buffer.remove(state.cursor_pos);
5738 changed = true;
5739 }
5740 }
5741 CtKeyCode::Left => {
5742 if state.cursor_pos > 0 {
5743 state.cursor_pos -= 1;
5744 changed = true;
5745 }
5746 }
5747 CtKeyCode::Right => {
5748 if state.cursor_pos < state.input_buffer.len() {
5749 state.cursor_pos += 1;
5750 changed = true;
5751 }
5752 }
5753 CtKeyCode::Home => {
5754 state.cursor_pos = 0;
5755 changed = true;
5756 }
5757 CtKeyCode::End => {
5758 state.cursor_pos = state.input_buffer.len();
5759 changed = true;
5760 }
5761 CtKeyCode::Enter => {
5762 let trimmed = state.input_buffer.trim().to_string();
5763 if !trimmed.is_empty() {
5764 if trimmed == "/stop" || trimmed == "/interrupt" {
5765 *interrupted = true;
5766 state.input_buffer.clear();
5767 state.cursor_pos = 0;
5768 changed = true;
5769 break;
5770 }
5771 print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, trimmed));
5772 state.queue_prompt(trimmed.clone());
5773 state.prompt_history.push(trimmed);
5774 state.input_buffer.clear();
5775 state.cursor_pos = 0;
5776 changed = true;
5777 }
5778 }
5779 CtKeyCode::Esc => {
5780 *interrupted = true;
5781 *interrupted_by_esc = true;
5782 changed = true;
5783 break;
5784 }
5785 _ => {}
5786 }
5787 }
5788 _ => {}
5789 }
5790 }
5791 changed
5792}
5793
5794fn drain_run_key_events_queue_only(state: &mut SessionState) -> bool {
5795 let mut changed = false;
5796 while ct_event::poll(Duration::from_millis(0)).unwrap_or(false) {
5797 let Ok(ev) = ct_event::read() else {
5798 continue;
5799 };
5800 match ev {
5801 CtEvent::Resize(_, _) => {
5802 changed = true;
5803 }
5804 CtEvent::Key(key) => {
5805 if key.kind != KeyEventKind::Press {
5806 continue;
5807 }
5808 match key.code {
5809 CtKeyCode::Char(c) => {
5810 state.input_buffer.insert(state.cursor_pos, c);
5811 state.cursor_pos += 1;
5812 changed = true;
5813 }
5814 CtKeyCode::Backspace => {
5815 if state.cursor_pos > 0 {
5816 state.cursor_pos -= 1;
5817 state.input_buffer.remove(state.cursor_pos);
5818 changed = true;
5819 }
5820 }
5821 CtKeyCode::Delete => {
5822 if state.cursor_pos < state.input_buffer.len() {
5823 state.input_buffer.remove(state.cursor_pos);
5824 changed = true;
5825 }
5826 }
5827 CtKeyCode::Left => {
5828 if state.cursor_pos > 0 {
5829 state.cursor_pos -= 1;
5830 changed = true;
5831 }
5832 }
5833 CtKeyCode::Right => {
5834 if state.cursor_pos < state.input_buffer.len() {
5835 state.cursor_pos += 1;
5836 changed = true;
5837 }
5838 }
5839 CtKeyCode::Home => {
5840 state.cursor_pos = 0;
5841 changed = true;
5842 }
5843 CtKeyCode::End => {
5844 state.cursor_pos = state.input_buffer.len();
5845 changed = true;
5846 }
5847 CtKeyCode::Enter => {
5848 let trimmed = state.input_buffer.trim().to_string();
5849 if !trimmed.is_empty() {
5850 print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, trimmed));
5851 state.queue_prompt(trimmed.clone());
5852 state.prompt_history.push(trimmed);
5853 state.input_buffer.clear();
5854 state.cursor_pos = 0;
5855 changed = true;
5856 }
5857 }
5858 CtKeyCode::Esc => {
5859 state.input_buffer.clear();
5860 state.cursor_pos = 0;
5861 changed = true;
5862 }
5863 _ => {}
5864 }
5865 }
5866 _ => {}
5867 }
5868 }
5869 changed
5870}
5871
5872fn render_ratatui_panel(title: &str, body: &str) -> String {
5873 let (width, _) = terminal_size();
5874 let width = width.min(140).max(20);
5875 let inner_width = width.saturating_sub(2).max(1);
5876 let wrapped = wrap(body, WrapOptions::new(inner_width));
5877 let height = wrapped.len().saturating_add(2).max(3);
5878 let rect = Rect::new(0, 0, width as u16, height as u16);
5879 let mut buf = Buffer::empty(rect);
5880 let paragraph = Paragraph::new(wrapped.join("\n"))
5881 .block(Block::default().title(title).borders(Borders::ALL));
5882 paragraph.render(rect, &mut buf);
5883 buffer_to_lines(buf, rect).join("\n")
5884}
5885
5886fn buffer_to_lines(buf: Buffer, rect: Rect) -> Vec<String> {
5887 let mut lines = Vec::new();
5888 for y in 0..rect.height {
5889 let mut line = String::new();
5890 for x in 0..rect.width {
5891 let cell = buf.get(x, y);
5892 line.push_str(cell.symbol());
5893 }
5894 lines.push(line.trim_end().to_string());
5895 }
5896 lines
5897}
5898
5899fn strip_ansi(s: &str) -> String {
5901 let mut result = String::new();
5902 let mut it = s.chars().peekable();
5903 while let Some(c) = it.next() {
5904 if c != '\x1b' {
5905 result.push(c);
5906 continue;
5907 }
5908
5909 match it.next() {
5911 Some('[') => {
5912 while let Some(ch) = it.next() {
5914 if ('@'..='~').contains(&ch) {
5915 break;
5916 }
5917 }
5918 }
5919 Some(']') => {
5920 while let Some(ch) = it.next() {
5922 if ch == '\x07' {
5923 break;
5924 }
5925 if ch == '\x1b' {
5926 if let Some('\\') = it.peek().copied() {
5927 let _ = it.next();
5928 break;
5929 }
5930 }
5931 }
5932 }
5933 Some(_) | None => {}
5934 }
5935 }
5936 result
5937}
5938
5939#[allow(dead_code)]
5940fn print_box(lines: &[String]) {
5941 let out = format_box_string(lines);
5942 if !out.is_empty() {
5943 println!("{out}");
5944 }
5945}
5946
5947fn truncate_line(input: &str, max: usize) -> String {
5948 if input.len() <= max {
5949 return input.to_string();
5950 }
5951 input.chars().take(max).collect()
5952}
5953
5954
5955fn truncate_middle(input: &str, max: usize) -> String {
5956 if input.len() <= max {
5957 return input.to_string();
5958 }
5959 let total = max;
5960 let head_len = total / 2;
5961 let tail_len = total - head_len;
5962 let head: String = input.chars().take(head_len).collect();
5963 let tail: String = input.chars().rev().take(tail_len).collect::<String>().chars().rev().collect();
5964 format!("{}{}", head, tail)
5965}
5966
5967fn format_root_path(path: &Path) -> String {
5968 let mut out = path.display().to_string();
5969 if let Ok(home) = std::env::var("HOME") {
5970 if out.starts_with(&home) {
5971 out = out.replacen(&home, "~", 1);
5972 }
5973 }
5974 truncate_middle(&out, 70)
5975}
5976
5977fn terminal_size() -> (usize, usize) {
5978 let term = ConsoleTerm::stdout();
5979 let (rows, cols) = term.size();
5980 let width = cols.max(1) as usize;
5981 let height = rows.max(1) as usize;
5982 (width, height)
5983}
5984
5985fn format_mode(mode: RunMode) -> &'static str {
5986 match mode {
5987 RunMode::Read => "read",
5988 RunMode::Work => "work",
5989 }
5990}
5991
5992fn format_approvals(mode: ApprovalMode) -> &'static str {
5993 match mode {
5994 ApprovalMode::Ask => "ask",
5995 ApprovalMode::Auto => "auto",
5996 }
5997}
5998
5999fn format_approvals_display(chat: &eli_core::config::ChatConfig) -> String {
6000 let cmds = chat.resolved_command_approvals();
6001 let diffs = chat.resolved_diff_approvals();
6002 if cmds == diffs {
6003 return format_approvals(cmds).to_string();
6004 }
6005 format!("cmd:{} diff:{}", format_approvals(cmds), format_approvals(diffs))
6006}
6007
6008fn parse_bool(val: &str) -> Result<bool> {
6009 match val.trim().to_ascii_lowercase().as_str() {
6010 "1" | "true" | "yes" | "on" => Ok(true),
6011 "0" | "false" | "no" | "off" => Ok(false),
6012 other => anyhow::bail!("invalid boolean value: {other}"),
6013 }
6014}
6015
6016async fn run_commands_with_policy(
6017 profile: AgentProfile,
6018 command_runner: &CommandRunner,
6019 commands: &[String],
6020 parallelism: usize,
6021) -> Vec<CommandResult> {
6022 let _ = profile;
6023 command_runner
6024 .run_commands_with_parallelism(commands, parallelism)
6025 .await
6026}
6027
6028fn shadow_large_tool_outputs(project_root: &Path, results: &[CommandResult]) -> Vec<CommandResult> {
6029 const MAX_INLINE_JSON_BYTES: usize = 2 * 1024;
6030
6031 let out_path = project_root
6032 .join("eli_research")
6033 .join("data")
6034 .join(".last_tool_output.json");
6035 let rel_path = out_path
6036 .strip_prefix(project_root)
6037 .map(|p| p.display().to_string())
6038 .unwrap_or_else(|_| out_path.display().to_string());
6039
6040 let mut out = Vec::with_capacity(results.len());
6041 for r in results {
6042 let mut rr = r.clone();
6043
6044 let cmd0 = rr
6045 .command
6046 .trim_start()
6047 .split_whitespace()
6048 .next()
6049 .unwrap_or("");
6050 let is_eli = cmd0 == "eli" || cmd0.ends_with("/eli") || cmd0.ends_with("\\eli");
6051 if !is_eli || !rr.allowed || rr.returncode != 0 {
6052 out.push(rr);
6053 continue;
6054 }
6055
6056 if is_suppression_exempt(&rr.command) {
6057 out.push(rr);
6058 continue;
6059 }
6060
6061 let stdout = rr.stdout.trim();
6062 if stdout.as_bytes().len() <= MAX_INLINE_JSON_BYTES {
6063 out.push(rr);
6064 continue;
6065 }
6066 if !(stdout.starts_with('{') || stdout.starts_with('[')) {
6067 out.push(rr);
6068 continue;
6069 }
6070
6071 let value: serde_json::Value = match serde_json::from_str(stdout) {
6072 Ok(v) => v,
6073 Err(_) => {
6074 out.push(rr);
6075 continue;
6076 }
6077 };
6078
6079 if let Some(parent) = out_path.parent() {
6080 if let Err(e) = std::fs::create_dir_all(parent) {
6081 rr.stderr = format!(
6082 "{}\n(data shadowing: failed to create dir '{}': {e})",
6083 rr.stderr.trim_end(),
6084 parent.display()
6085 )
6086 .trim()
6087 .to_string();
6088 out.push(rr);
6089 continue;
6090 }
6091 }
6092
6093 let json = serde_json::to_string_pretty(&value).unwrap_or_else(|_| stdout.to_string());
6094 if let Err(e) = std::fs::write(&out_path, &json) {
6095 rr.stderr = format!(
6096 "{}\n(data shadowing: failed to write '{}': {e})",
6097 rr.stderr.trim_end(),
6098 rel_path
6099 )
6100 .trim()
6101 .to_string();
6102 out.push(rr);
6103 continue;
6104 }
6105
6106 let audit_path = {
6107 let stamp = chrono::Utc::now().format("%Y%m%d_%H%M%S%3f");
6108 out_path
6109 .parent()
6110 .unwrap_or(project_root)
6111 .join(format!("tool_output_{stamp}.json"))
6112 };
6113 let rel_audit_path = audit_path
6114 .strip_prefix(project_root)
6115 .map(|p| p.display().to_string())
6116 .unwrap_or_else(|_| audit_path.display().to_string());
6117 let audit_ok = match std::fs::write(&audit_path, &json) {
6118 Ok(()) => true,
6119 Err(e) => {
6120 rr.stderr = format!(
6121 "{}\n(data shadowing: failed to write '{}': {e})",
6122 rr.stderr.trim_end(),
6123 rel_audit_path
6124 )
6125 .trim()
6126 .to_string();
6127 false
6128 }
6129 };
6130
6131 let points = count_data_points(&value);
6132 let summary = format_suppressed_summary(&value, 8, 160);
6133 let hint = "More detail is available in the saved file; inspect with local tools if needed.";
6134 let bytes = json.as_bytes().len();
6135 let audit_fragment = if audit_ok {
6136 format!("; saved_copy={rel_audit_path}")
6137 } else {
6138 String::new()
6139 };
6140 rr.stdout = format!(
6141 "[OUTPUT SUPPRESSED] saved_to={rel_path} ({bytes} bytes){audit_fragment}. Data points: {points}.\n[SUMMARY]\n{summary}\n{hint}"
6142 );
6143 out.push(rr);
6144 }
6145
6146 out
6147}
6148
6149fn format_suppressed_summary(
6150 value: &serde_json::Value,
6151 max_lines: usize,
6152 max_field_len: usize,
6153) -> String {
6154 fn trunc(s: String, max_len: usize) -> String {
6155 if s.chars().count() <= max_len {
6156 return s;
6157 }
6158 let mut out: String = s.chars().take(max_len).collect();
6159 out.push('…');
6160 out
6161 }
6162
6163 fn list_sample(items: Vec<String>, max_items: usize, max_len: usize) -> String {
6164 let mut out = items
6165 .into_iter()
6166 .filter(|s| !s.is_empty())
6167 .take(max_items)
6168 .map(|s| trunc(s, max_len))
6169 .collect::<Vec<_>>()
6170 .join(", ");
6171 if out.is_empty() {
6172 out = "n/a".to_string();
6173 }
6174 out
6175 }
6176
6177 let mut lines: Vec<String> = Vec::new();
6178
6179 match value {
6180 serde_json::Value::Object(map) => {
6181 let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
6182 keys.sort();
6183 lines.push(format!("top_level_keys: {}", keys.join(", ")));
6184
6185 if let Some(provider) = map.get("provider").and_then(|v| v.as_str()) {
6186 lines.push(format!("provider: {provider}"));
6187 }
6188
6189 if let Some(tickers) = map.get("tickers").and_then(|v| v.as_array()) {
6190 let tickers = tickers
6191 .iter()
6192 .filter_map(|v| v.as_str().map(|s| s.to_string()))
6193 .collect::<Vec<_>>();
6194 if !tickers.is_empty() {
6195 lines.push(format!(
6196 "tickers: {}",
6197 list_sample(tickers, 10, max_field_len)
6198 ));
6199 }
6200 }
6201
6202 if let Some(arr) = map.get("available_events").and_then(|v| v.as_array()) {
6203 lines.push(format!("available_events: {}", arr.len()));
6204 let sample = arr
6205 .iter()
6206 .take(3)
6207 .filter_map(|v| {
6208 let title = v.get("title").and_then(|s| s.as_str());
6209 let ticker = v.get("ticker").and_then(|s| s.as_str());
6210 match (ticker, title) {
6211 (Some(t), Some(tt)) => Some(format!("{t}: {tt}")),
6212 (None, Some(tt)) => Some(tt.to_string()),
6213 _ => None,
6214 }
6215 })
6216 .collect::<Vec<_>>();
6217 if !sample.is_empty() {
6218 lines.push(format!("event_samples: {}", list_sample(sample, 3, max_field_len)));
6219 }
6220 }
6221
6222 if let Some(arr) = map.get("available_tags").and_then(|v| v.as_array()) {
6223 lines.push(format!("available_tags: {}", arr.len()));
6224 let sample = arr
6225 .iter()
6226 .take(3)
6227 .filter_map(|v| {
6228 let label = v.get("label").and_then(|s| s.as_str());
6229 let slug = v.get("slug").and_then(|s| s.as_str());
6230 let id = v.get("id").and_then(|s| s.as_str());
6231 match (label, slug, id) {
6232 (Some(l), _, _) => Some(l.to_string()),
6233 (None, Some(s), _) => Some(s.to_string()),
6234 (None, None, Some(i)) => Some(i.to_string()),
6235 _ => None,
6236 }
6237 })
6238 .collect::<Vec<_>>();
6239 if !sample.is_empty() {
6240 lines.push(format!("tag_samples: {}", list_sample(sample, 3, max_field_len)));
6241 }
6242 }
6243
6244 if let Some(arr) = map.get("markets").and_then(|v| v.as_array()) {
6245 lines.push(format!("markets: {}", arr.len()));
6246 let sample = arr
6247 .iter()
6248 .take(3)
6249 .filter_map(|v| {
6250 let title = v.get("title").and_then(|s| s.as_str());
6251 let ticker = v.get("ticker").and_then(|s| s.as_str());
6252 match (ticker, title) {
6253 (Some(t), Some(tt)) => Some(format!("{t}: {tt}")),
6254 (None, Some(tt)) => Some(tt.to_string()),
6255 _ => None,
6256 }
6257 })
6258 .collect::<Vec<_>>();
6259 if !sample.is_empty() {
6260 lines.push(format!("market_samples: {}", list_sample(sample, 3, max_field_len)));
6261 }
6262 }
6263
6264 if let Some(arr) = map.get("series").and_then(|v| v.as_array()) {
6265 let mut tickers = Vec::new();
6266 let mut total_points = 0usize;
6267 for s in arr {
6268 if let Some(t) = s.get("ticker").and_then(|v| v.as_str()) {
6269 tickers.push(t.to_string());
6270 }
6271 if let Some(candles) = s.get("candles").and_then(|v| v.as_array()) {
6272 total_points += candles.len();
6273 }
6274 }
6275 lines.push(format!("series: {}", arr.len()));
6276 if !tickers.is_empty() {
6277 lines.push(format!(
6278 "series_tickers: {}",
6279 list_sample(tickers, 10, max_field_len)
6280 ));
6281 }
6282 if total_points > 0 {
6283 lines.push(format!("series_points: {total_points}"));
6284 }
6285 }
6286
6287 if let Some(arr) = map.get("snapshots").and_then(|v| v.as_array()) {
6288 lines.push(format!("snapshots: {}", arr.len()));
6289 let sample = arr
6290 .iter()
6291 .take(3)
6292 .filter_map(|v| {
6293 let t = v.get("ticker").and_then(|s| s.as_str())?;
6294 let p = v.get("current_price").and_then(|s| s.as_f64());
6295 Some(match p {
6296 Some(px) => format!("{t}={px:.2}"),
6297 None => t.to_string(),
6298 })
6299 })
6300 .collect::<Vec<_>>();
6301 if !sample.is_empty() {
6302 lines.push(format!("snapshot_samples: {}", list_sample(sample, 3, max_field_len)));
6303 }
6304 }
6305
6306 if let Some(arr) = map.get("prices").and_then(|v| v.as_array()) {
6307 lines.push(format!("prices: {}", arr.len()));
6308 let sample = arr
6309 .iter()
6310 .take(3)
6311 .filter_map(|v| {
6312 let sym = v.get("symbol").and_then(|s| s.as_str())?;
6313 let val = v.get("value").and_then(|s| s.as_f64());
6314 Some(match val {
6315 Some(px) => format!("{sym}={px:.4}"),
6316 None => sym.to_string(),
6317 })
6318 })
6319 .collect::<Vec<_>>();
6320 if !sample.is_empty() {
6321 lines.push(format!("price_samples: {}", list_sample(sample, 3, max_field_len)));
6322 }
6323 }
6324
6325 if let Some(arr) = map.get("filings").and_then(|v| v.as_array()) {
6326 lines.push(format!("filings: {}", arr.len()));
6327 let sample = arr
6328 .iter()
6329 .take(3)
6330 .filter_map(|v| {
6331 let form = v.get("form").and_then(|s| s.as_str())?;
6332 let date = v.get("filing_date").and_then(|s| s.as_str());
6333 Some(match date {
6334 Some(d) => format!("{form} ({d})"),
6335 None => form.to_string(),
6336 })
6337 })
6338 .collect::<Vec<_>>();
6339 if !sample.is_empty() {
6340 lines.push(format!("filing_samples: {}", list_sample(sample, 3, max_field_len)));
6341 }
6342 }
6343
6344 if let Some(arr) = map.get("indicators").and_then(|v| v.as_array()) {
6345 lines.push(format!("indicators: {}", arr.len()));
6346 let sample = arr
6347 .iter()
6348 .take(3)
6349 .filter_map(|v| {
6350 let sym = v.get("symbol").and_then(|s| s.as_str())?;
6351 let val = v.get("current_value").and_then(|s| s.as_f64());
6352 Some(match val {
6353 Some(px) => format!("{sym}={px:.3}"),
6354 None => sym.to_string(),
6355 })
6356 })
6357 .collect::<Vec<_>>();
6358 if !sample.is_empty() {
6359 lines.push(format!("indicator_samples: {}", list_sample(sample, 3, max_field_len)));
6360 }
6361 }
6362
6363 if let Some(data) = map.get("data") {
6364 if let serde_json::Value::Object(data_obj) = data {
6365 let mut child_keys: Vec<&str> =
6366 data_obj.keys().map(|k| k.as_str()).collect();
6367 child_keys.sort();
6368 lines.push(format!("data_keys: {}", child_keys.join(", ")));
6369 for key in child_keys.iter().take(4) {
6370 if let Some(arr) = data_obj.get(*key).and_then(|v| v.as_array()) {
6371 lines.push(format!("data.{key}: {}", arr.len()));
6372 }
6373 }
6374 }
6375 }
6376 }
6377 serde_json::Value::Array(arr) => {
6378 lines.push(format!("top_level: array (len={})", arr.len()));
6379 }
6380 _ => {
6381 lines.push("top_level: scalar".to_string());
6382 }
6383 }
6384
6385 let trimmed = lines.into_iter().take(max_lines).collect::<Vec<_>>();
6386 trimmed.into_iter().map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
6387}
6388
6389fn augment_tool_errors(results: Vec<CommandResult>) -> Vec<CommandResult> {
6390 results
6391 .into_iter()
6392 .map(|mut r| {
6393 if !r.allowed || r.returncode == 0 {
6394 return r;
6395 }
6396
6397 if !looks_like_clap_error(&r.stderr) {
6398 return r;
6399 }
6400
6401 let path = match extract_eli_tool_path(&r.command) {
6402 Some(path) => path,
6403 None => return r,
6404 };
6405
6406 if path.first().map(|p| p.as_str()) == Some("tool-info") {
6407 return r;
6408 }
6409
6410 let info = build_tool_info(&path);
6411 let info_json =
6412 serde_json::to_string_pretty(&info).unwrap_or_else(|_| "<tool-info failed>".to_string());
6413 let sep = if r.stderr.trim().is_empty() { "" } else { "\n" };
6414 r.stderr = format!(
6415 "{}{}[TOOL INFO]\n{}",
6416 r.stderr.trim_end(),
6417 sep,
6418 info_json
6419 );
6420 r
6421 })
6422 .collect()
6423}
6424
6425fn looks_like_clap_error(stderr: &str) -> bool {
6426 let lower = stderr.to_ascii_lowercase();
6427 lower.contains("error:") && (lower.contains("usage:") || lower.contains("try '--help'"))
6428}
6429
6430fn extract_eli_tool_path(command: &str) -> Option<Vec<String>> {
6431 let mut parts = command.split_whitespace();
6432 let first = parts.next()?;
6433 let is_eli = first == "eli" || first.ends_with("/eli") || first.ends_with("\\eli");
6434 if !is_eli {
6435 return None;
6436 }
6437
6438 let mut path = Vec::new();
6439 for tok in parts {
6440 if tok.starts_with('-') {
6441 break;
6442 }
6443 path.push(tok.to_string());
6444 }
6445
6446 if path.is_empty() {
6447 None
6448 } else {
6449 Some(path)
6450 }
6451}
6452
6453fn is_suppression_exempt(command: &str) -> bool {
6454 let trimmed = command.trim_start();
6455 if trimmed.is_empty() {
6456 return false;
6457 }
6458
6459 let lower = trimmed.to_ascii_lowercase();
6460 let mut parts = lower.split_whitespace();
6461 let Some(bin) = parts.next() else {
6462 return false;
6463 };
6464
6465 let is_eli = bin == "eli" || bin.ends_with("/eli") || bin.ends_with("\\eli");
6466 if !is_eli {
6467 return false;
6468 }
6469
6470 let Some(domain) = parts.next() else {
6471 return false;
6472 };
6473 if domain != "finance" {
6474 return false;
6475 }
6476
6477 let Some(tool) = parts.next() else {
6478 return false;
6479 };
6480
6481 match tool {
6482 "search" => true,
6483 "odds" => {
6484 let rest = parts.collect::<Vec<_>>();
6485 rest.iter().any(|t| *t == "--list-events" || *t == "--list-series")
6486 }
6487 "options" => {
6488 let rest = parts.collect::<Vec<_>>();
6489 rest.iter().any(|t| *t == "--expirations")
6490 }
6491 _ => false,
6492 }
6493}
6494
6495fn infer_sources(command: &str, stdout: &str) -> Vec<&'static str> {
6496 let cmd_lower = command.to_ascii_lowercase();
6497 let mut out: Vec<&'static str> = Vec::new();
6498
6499 if cmd_lower.contains("eli finance odds") {
6500 let out_lower = stdout.to_ascii_lowercase();
6501 if out_lower.contains("kalshi") {
6502 out.push("Kalshi");
6503 }
6504 if out_lower.contains("polymarket") {
6505 out.push("Polymarket");
6506 }
6507 return dedupe_sources(out);
6508 }
6509
6510 if cmd_lower.contains("eli finance prices") {
6511 out.push("Pyth");
6512 return out;
6513 }
6514
6515 if cmd_lower.contains("eli finance") {
6516 if let Some(source) = infer_sources_from_json(stdout) {
6517 out.extend(source);
6518 return dedupe_sources(out);
6519 }
6520 if cmd_lower.contains("--provider fred") {
6521 out.push("FRED");
6522 } else if cmd_lower.contains("--provider yahoo") {
6523 out.push("Yahoo Finance");
6524 } else if cmd_lower.contains("--provider mock") {
6525 out.push("Mock");
6526 }
6527 }
6528
6529 dedupe_sources(out)
6530}
6531
6532fn infer_sources_from_json(stdout: &str) -> Option<Vec<&'static str>> {
6533 let value: serde_json::Value = serde_json::from_str(stdout).ok()?;
6534 let mut out: Vec<&'static str> = Vec::new();
6535
6536 if let Some(provider) = value.get("provider").and_then(|v| v.as_str()) {
6537 match provider {
6538 "yahoo" => out.push("Yahoo Finance"),
6539 "fred" => out.push("FRED"),
6540 "mock" => out.push("Mock"),
6541 _ => {}
6542 }
6543 }
6544
6545 if let Some(source) = value.get("source").and_then(|v| v.as_str()) {
6546 match source {
6547 "pyth" => out.push("Pyth"),
6548 "kalshi" => out.push("Kalshi"),
6549 "polymarket" => out.push("Polymarket"),
6550 _ => {}
6551 }
6552 }
6553
6554 if let Some(sources) = value.get("sources").and_then(|v| v.as_array()) {
6555 for s in sources {
6556 if let Some(name) = s.get("source").and_then(|v| v.as_str()) {
6557 match name {
6558 "kalshi" => out.push("Kalshi"),
6559 "polymarket" => out.push("Polymarket"),
6560 "pyth" => out.push("Pyth"),
6561 "fred" => out.push("FRED"),
6562 "yahoo" => out.push("Yahoo Finance"),
6563 "mock" => out.push("Mock"),
6564 _ => {}
6565 }
6566 }
6567 }
6568 }
6569
6570 if out.is_empty() {
6571 None
6572 } else {
6573 Some(dedupe_sources(out))
6574 }
6575}
6576
6577fn dedupe_sources(mut sources: Vec<&'static str>) -> Vec<&'static str> {
6578 sources.sort_unstable();
6579 sources.dedup();
6580 sources
6581}
6582
6583fn count_data_points(value: &serde_json::Value) -> usize {
6584 fn array_len(v: Option<&serde_json::Value>) -> Option<usize> {
6585 v.and_then(|vv| vv.as_array().map(|a| a.len()))
6586 }
6587
6588 match value {
6589 serde_json::Value::Array(arr) => arr.len(),
6590 serde_json::Value::Object(map) => {
6591 if let Some(series) = map.get("series").and_then(|v| v.as_array()) {
6592 let mut total = 0usize;
6593 for s in series {
6594 total += s
6595 .get("candles")
6596 .and_then(|v| v.as_array())
6597 .map(|a| a.len())
6598 .unwrap_or(0);
6599 }
6600 if total > 0 {
6601 return total;
6602 }
6603 }
6604
6605 if let Some(n) = array_len(map.get("snapshots")) {
6606 return n;
6607 }
6608 if let Some(n) = array_len(map.get("prices")) {
6609 return n;
6610 }
6611 if let Some(n) = array_len(map.get("available_events")) {
6612 return n;
6613 }
6614 if let Some(n) = array_len(map.get("available_tags")) {
6615 return n;
6616 }
6617 if let Some(n) = array_len(map.get("events")) {
6618 return n;
6619 }
6620 if let Some(n) = array_len(map.get("markets")) {
6621 return n;
6622 }
6623 if let Some(n) = array_len(map.get("results")) {
6624 return n;
6625 }
6626
6627 map.len()
6628 }
6629 _ => 1,
6630 }
6631}
6632
6633fn build_observation(
6634 read_mode: bool,
6635 approvals_ask_commands: bool,
6636 approvals_ask_diffs: bool,
6637 diffs: &[DiffResult],
6638 commands: &[CommandResult],
6639) -> String {
6640 let mode = if read_mode { "read" } else { "work" };
6641 let approvals_cmds = if approvals_ask_commands { "ask" } else { "auto" };
6642 let approvals_diffs = if approvals_ask_diffs { "ask" } else { "auto" };
6643
6644 let mut out = String::new();
6645 out.push_str(&format!(
6646 "mode={mode}, approvals_cmds={approvals_cmds}, approvals_diffs={approvals_diffs}\n"
6647 ));
6648
6649 if !diffs.is_empty() {
6650 out.push_str("diffs:\n");
6651 for r in diffs {
6652 out.push_str(&format!(
6653 "- {op} {path}: {status} {msg}\n",
6654 op = r.op,
6655 path = r.path,
6656 status = if r.success { "OK" } else { "ERR" },
6657 msg = r.message
6658 ));
6659 }
6660 }
6661
6662 if !commands.is_empty() {
6663 out.push_str("commands:\n");
6664 for r in commands {
6665 out.push_str(&format!(
6666 "- `{cmd}` => {code} ({ms}ms)\n",
6667 cmd = r.command,
6668 code = r.returncode,
6669 ms = r.duration_ms
6670 ));
6671 let digest = build_command_digest(r);
6672 if !digest.trim().is_empty() {
6673 out.push_str(&format!(" digest: {digest}\n"));
6674 }
6675 if !r.stdout.trim().is_empty() {
6676 out.push_str(&format!(" stdout:\n{}\n", truncate(&r.stdout, 400000)));
6677 }
6678 if !r.stderr.trim().is_empty() {
6679 out.push_str(&format!(" stderr:\n{}\n", truncate(&r.stderr, 400000)));
6680 }
6681 }
6682 }
6683
6684 out
6685}
6686
6687fn truncate(s: &str, max: usize) -> String {
6688 if s.len() <= max {
6689 return s.to_string();
6690 }
6691 let mut out = String::new();
6692 for (idx, ch) in s.char_indices() {
6693 if idx >= max {
6694 break;
6695 }
6696 out.push(ch);
6697 }
6698 out
6699}
6700
6701fn insert_system_context_before_conversation(messages: &mut Vec<ChatMessage>, extra: ChatMessage) {
6702 let mut idx = 0usize;
6705 while idx < messages.len() {
6706 if !matches!(messages[idx].role, eli_core::types::Role::System) {
6707 break;
6708 }
6709 idx += 1;
6710 }
6711 messages.insert(idx, extra);
6712}
6713
6714fn discover_recent_research(project_root: &Path, max_items: usize) -> Vec<ResearchArtifact> {
6715 if max_items == 0 {
6716 return Vec::new();
6717 }
6718
6719 let dir = project_root.join("eli_research");
6720 let entries = match std::fs::read_dir(&dir) {
6721 Ok(it) => it,
6722 Err(_) => return Vec::new(),
6723 };
6724
6725 #[derive(Clone)]
6726 struct Candidate {
6727 path: PathBuf,
6728 modified: std::time::SystemTime,
6729 }
6730
6731 let mut files: Vec<Candidate> = Vec::new();
6732 for entry in entries.flatten() {
6733 let path = entry.path();
6734 if !path.is_file() {
6735 continue;
6736 }
6737 if path.file_name().and_then(|s| s.to_str()) == Some("ELI.md") {
6738 continue;
6739 }
6740 if path.extension().and_then(|s| s.to_str()) != Some("md") {
6741 continue;
6742 }
6743 let Ok(meta) = entry.metadata() else {
6744 continue;
6745 };
6746 let Ok(modified) = meta.modified() else {
6747 continue;
6748 };
6749 files.push(Candidate { path, modified });
6750 }
6751
6752 files.sort_by(|a, b| b.modified.cmp(&a.modified));
6753 files.truncate(max_items);
6754
6755 let mut out = Vec::new();
6756 for cand in files {
6757 let rel = cand
6758 .path
6759 .strip_prefix(project_root)
6760 .unwrap_or(&cand.path)
6761 .to_string_lossy()
6762 .to_string();
6763
6764 let title = read_markdown_title(&cand.path).unwrap_or_else(|| {
6765 cand.path
6766 .file_name()
6767 .and_then(|s| s.to_str())
6768 .unwrap_or("research")
6769 .to_string()
6770 });
6771
6772 let created_utc = chrono::DateTime::<chrono::Utc>::from(cand.modified).to_rfc3339();
6773
6774 out.push(ResearchArtifact {
6775 rel_path: rel,
6776 title,
6777 status: String::new(),
6778 created_utc,
6779 answer_hint: None,
6780 });
6781 }
6782
6783 out
6784}
6785
6786fn read_markdown_title(path: &Path) -> Option<String> {
6787 use std::io::Read;
6788
6789 let f = std::fs::File::open(path).ok()?;
6790 let mut buf = Vec::new();
6791 let mut reader = f.take(2048);
6793 reader.read_to_end(&mut buf).ok()?;
6794 let s = String::from_utf8_lossy(&buf);
6795 let first = s.lines().next()?.trim();
6796 let title = first.strip_prefix('#')?.trim();
6797 if title.is_empty() {
6798 None
6799 } else {
6800 Some(title.to_string())
6801 }
6802}
6803
6804fn is_slash_command_context(line: &str, pos: usize) -> bool {
6805 if pos != line.len() {
6806 return false;
6807 }
6808 if !line.starts_with('/') {
6809 return false;
6810 }
6811 if line.chars().any(|c| c.is_whitespace()) {
6812 return false;
6813 }
6814 let tail = line.get(1..).unwrap_or("");
6815 if tail.contains('/') {
6816 return false;
6817 }
6818 true
6819}
6820
6821fn confirm(prompt: &str) -> Result<bool> {
6822 use std::io::Write;
6823 print!(
6824 "{}?{} {} {}(y/n):{} ",
6825 style::YELLOW, style::RESET,
6826 prompt,
6827 style::GRAY, style::RESET
6828 );
6829 std::io::stdout().flush().ok();
6830 let mut input = String::new();
6831 std::io::stdin()
6832 .read_line(&mut input)
6833 .context("read confirm input")?;
6834 let v = input.trim().to_lowercase();
6835 Ok(v == "y" || v == "yes")
6836}
6837
6838fn prompt_user(prompt: &str) -> Result<(String, Vec<String>)> {
6839 use std::io::Write;
6840 println!(
6841 "\n{}?{} {}",
6842 style::CYAN, style::RESET,
6843 prompt
6844 );
6845 print!("{}›{} ", style::CYAN, style::RESET);
6846 std::io::stdout().flush().ok();
6847 let mut input = String::new();
6848 std::io::stdin().read_line(&mut input).context("read input")?;
6849 Ok(process_input_for_images(input.trim()))
6850}
6851
6852fn colorize_diff(diff: &str) -> String {
6853 use style::*;
6854
6855 let mut out = String::new();
6856 for line in diff.lines() {
6857 if line.starts_with('+') && !line.starts_with("+++") {
6858 out.push_str(&format!("{} {}{}\n", GREEN, line, RESET));
6859 } else if line.starts_with('-') && !line.starts_with("---") {
6860 out.push_str(&format!("{} {}{}\n", RED, line, RESET));
6861 } else if line.starts_with("@@") {
6862 out.push_str(&format!("{} {}{}\n", CYAN, line, RESET));
6863 } else if line.starts_with("+++") || line.starts_with("---") {
6864 out.push_str(&format!("{} {}{}\n", GRAY, line, RESET));
6865 } else {
6866 out.push_str(&format!(" {}\n", line));
6867 }
6868 }
6869 out
6870}
6871
6872fn diff_line_counts(diff: &str) -> (usize, usize) {
6873 let mut added = 0usize;
6874 let mut deleted = 0usize;
6875 for line in diff.lines() {
6876 if line.starts_with('+') && !line.starts_with("+++") {
6877 added += 1;
6878 } else if line.starts_with('-') && !line.starts_with("---") {
6879 deleted += 1;
6880 }
6881 }
6882 (added, deleted)
6883}
6884
6885fn print_diff_results(results: &[DiffResult], preview: bool, brief: bool) {
6886 use style::*;
6887
6888 if results.is_empty() {
6889 return;
6890 }
6891 if brief {
6892 let created = results.iter().filter(|r| r.op == "create").count();
6893 let modified = results.iter().filter(|r| r.op == "replace" || r.op == "patch").count();
6894 let deleted = results.iter().filter(|r| r.op == "delete").count();
6895
6896 let mut parts = Vec::new();
6897 if created > 0 { parts.push(format!("{}+{} created{}", GREEN, created, RESET)); }
6898 if modified > 0 { parts.push(format!("{}~{} modified{}", YELLOW, modified, RESET)); }
6899 if deleted > 0 { parts.push(format!("{}-{} deleted{}", RED, deleted, RESET)); }
6900
6901 let status = if preview {
6902 format!("{}preview{}", GRAY, RESET)
6903 } else {
6904 format!("{}applied{}", GREEN, RESET)
6905 };
6906 let count = created + modified + deleted;
6907 let noun = if count == 1 { "file" } else { "files" };
6908 print_history_line(format!("edited {count} {noun} ({})", status));
6909 return;
6910 }
6911
6912 let status = if preview { "preview" } else { "applied" };
6913 println!("{}◆{} diffs: {} ({})", PURPLE, RESET, results.len(), status);
6914 for r in results {
6915 let (icon, color) = if r.success { ("✓", GREEN) } else { ("✗", RED) };
6916 println!(
6917 " {}{}{} {}{} {}{}{}: {}",
6918 color, icon, RESET,
6919 BLUE, r.op, RESET,
6920 WHITE, r.path, RESET,
6921 );
6922 if !r.message.is_empty() && r.message != "ok" {
6923 println!(" {}{}{}", GRAY, r.message, RESET);
6924 }
6925 if let Some(d) = &r.diff {
6926 let (added, deleted) = diff_line_counts(d);
6927 println!(
6928 " LINE CODED ({}{}{} IN GREEN, {}{}{} IN RED)",
6929 GREEN, added, RESET, RED, deleted, RESET
6930 );
6931 println!("{}", colorize_diff(d));
6932 }
6933 }
6934}
6935
6936fn print_command_results(results: &[CommandResult], brief: bool, full: bool) {
6937 use style::*;
6938
6939 if results.is_empty() {
6940 return;
6941 }
6942
6943 if brief {
6944 for r in results {
6945 let (icon, color) = if r.returncode == 0 { ("✓", GREEN) } else { ("✗", RED) };
6946 print_history_line(format!(
6947 "{}{}{} {}${} {}{}",
6948 color, icon, RESET,
6949 GRAY, RESET,
6950 truncate_line(&r.command, 70),
6951 RESET
6952 ));
6953 if r.returncode != 0 && !r.stderr.trim().is_empty() {
6954 print_history_line(format!(
6955 "{}err:{} {}{}",
6956 RED,
6957 RESET,
6958 truncate_line(&r.stderr.replace('\n', " "), 100),
6959 RESET
6960 ));
6961 }
6962 }
6963 return;
6964 }
6965
6966 println!("{}◆{} commands: {}", YELLOW, RESET, results.len());
6967 for r in results {
6968 let (icon, color) = if r.returncode == 0 { ("✓", GREEN) } else { ("✗", RED) };
6969 println!(
6970 " {}{}{} {}${} {} {}{}ms{}",
6971 color, icon, RESET,
6972 GRAY, RESET,
6973 r.command,
6974 DARK_GRAY, r.duration_ms, RESET
6975 );
6976 if full {
6977 if !r.stdout.trim().is_empty() {
6978 println!(" {}stdout:{}{}", GRAY, RESET, RESET);
6979 for line in r.stdout.lines() {
6980 println!(" {}{}{}", GRAY, line, RESET);
6981 }
6982 }
6983 if !r.stderr.trim().is_empty() {
6984 println!(" {}stderr:{}{}", RED, RESET, RESET);
6985 for line in r.stderr.lines() {
6986 println!(" {}{}{}", RED, line, RESET);
6987 }
6988 }
6989 } else {
6990 if !r.stdout.trim().is_empty() {
6991 for line in r.stdout.lines().take(20) {
6992 println!(" {}{}{}", GRAY, line, RESET);
6993 }
6994 if r.stdout.lines().count() > 20 {
6995 println!(" {}... ({} more lines){}", DARK_GRAY, r.stdout.lines().count() - 20, RESET);
6996 }
6997 }
6998 if !r.stderr.trim().is_empty() {
6999 for line in r.stderr.lines().take(10) {
7000 println!(" {}{}{}", RED, line, RESET);
7001 }
7002 }
7003 }
7004 }
7005}
7006
7007fn print_tool_results_debug(results: &[CommandResult]) {
7008 if results.is_empty() {
7009 return;
7010 }
7011
7012 println!("\n=== TOOL CALL RESULT ===");
7013 for (idx, r) in results.iter().enumerate() {
7014 if idx > 0 {
7015 println!("\n---");
7016 }
7017 println!("command: {}", r.command);
7018 println!("returncode: {}", r.returncode);
7019 if let Some(reason) = &r.deny_reason {
7020 println!("deny_reason: {}", reason);
7021 }
7022 println!("stdout:");
7023 print!("{}", r.stdout);
7024 if !r.stdout.ends_with('\n') {
7025 println!();
7026 }
7027 println!("stderr:");
7028 print!("{}", r.stderr);
7029 if !r.stderr.ends_with('\n') {
7030 println!();
7031 }
7032 }
7033 println!("=== END TOOL CALL RESULT ===");
7034}
7035
7036async fn print_screen_results(actions: &[serde_json::Value]) {
7037 for action in actions {
7038 let Some(obj) = action.as_object() else {
7039 continue;
7040 };
7041 let Some(kind) = obj.get("action").and_then(|v| v.as_str()) else {
7042 continue;
7043 };
7044 match kind {
7045 "clipboard" => {
7046 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
7047 let _ = eli_screen::run_action(eli_screen::ScreenAction::Clipboard {
7048 text: text.to_string(),
7049 })
7050 .await;
7051 println!("screen: clipboard ({} chars)", text.len());
7052 }
7053 }
7054 "focus_app" => {
7055 if let Some(name) = obj.get("app").and_then(|v| v.as_str()) {
7056 let _ = eli_screen::run_action(eli_screen::ScreenAction::FocusApp {
7057 name: name.to_string(),
7058 })
7059 .await;
7060 println!("screen: focus_app {name}");
7061 }
7062 }
7063 other => println!("screen: skipped action {other}"),
7064 }
7065 }
7066}
7067
7068fn parse_plan_controls(plan: &str) -> (Option<RunMode>, Option<ApprovalMode>) {
7069 let line = plan.lines().next().unwrap_or("");
7070 let mut mode = None;
7071 let mut approvals = None;
7072
7073 for part in line.split('|').map(|p| p.trim()) {
7074 let lower = part.to_ascii_lowercase();
7075 if let Some(rest) = lower.strip_prefix("mode:") {
7076 let v = rest.trim();
7077 mode = match v {
7078 "read" => Some(RunMode::Read),
7079 "work" => Some(RunMode::Work),
7080 _ => None,
7081 };
7082 } else if let Some(rest) = lower.strip_prefix("approvals:") {
7083 let v = rest.trim();
7084 approvals = match v {
7085 "ask" => Some(ApprovalMode::Ask),
7086 "auto" => Some(ApprovalMode::Auto),
7087 _ => None,
7088 };
7089 }
7090 }
7091
7092 (mode, approvals)
7093}
7094
7095fn print_cost_stats(state: &SessionState, chat: &eli_core::config::ChatConfig) {
7096 use style::*;
7097
7098 let usage = &state.total_usage;
7099 let cost = estimate_cost(usage, &chat.model);
7100
7101 let lines = vec![
7102 format!("{}{}Cost & Usage{}", BOLD, CYAN, RESET),
7103 String::new(),
7104 format!(
7105 "{}total{} {} tokens {}│{} {}${} {:.4}{}",
7106 GRAY, RESET,
7107 usage.total_tokens,
7108 DARK_GRAY, RESET,
7109 GREEN, RESET, cost, RESET
7110 ),
7111 format!(
7112 "{} {} in {} out",
7113 GRAY, usage.prompt_tokens,
7114 usage.completion_tokens
7115 ),
7116 ];
7117
7118 if let Some(last) = &state.last_usage {
7119 let last_cost = estimate_cost(last, &chat.model);
7120 let mut extended = lines;
7121 extended.push(String::new());
7122 extended.push(format!(
7123 "{}last{} {} tokens {}${:.4}{}",
7124 GRAY, RESET,
7125 last.total_tokens,
7126 YELLOW, last_cost, RESET
7127 ));
7128 let out = format_indented_block(&extended);
7129 println!("{}", out);
7130 } else {
7131 let out = format_indented_block(&lines);
7132 println!("{}", out);
7133 }
7134}
7135
7136fn estimate_cost(usage: &eli_core::types::Usage, model: &str) -> f64 {
7137 let m = model.to_lowercase();
7140 let (input_rate, output_rate) = if m.contains("claude-3-5-sonnet") {
7141 (3.0, 15.0)
7142 } else if m.contains("claude-3-5-haiku") {
7143 (0.8, 4.0)
7144 } else if m.contains("claude-3-haiku") || m.contains("haiku") {
7145 (0.25, 1.25)
7146 } else if m.contains("claude-3-opus") || m.contains("opus") {
7147 (15.0, 75.0)
7148 } else if m.contains("gpt-4o-mini") {
7149 (0.15, 0.60)
7150 } else if m.contains("gpt-4o") {
7151 (2.5, 10.0)
7152 } else if m.contains("o1-mini") {
7153 (1.1, 4.4)
7154 } else if m.contains("o1") {
7155 (15.0, 60.0)
7156 } else if m.contains("o3-mini") {
7157 (1.1, 4.4)
7158 } else if m.contains("gpt-4-turbo") || m.contains("gpt-4") {
7159 (10.0, 30.0)
7160 } else if m.contains("deepseek") {
7161 (0.14, 0.28)
7162 } else if m.contains("gemini-1.5-flash") {
7163 (0.075, 0.3)
7164 } else if m.contains("gemini-1.5-pro") {
7165 (1.25, 5.0)
7166 } else if m.contains("llama-3.1-405b") || m.contains("llama-3.3-70b") {
7167 (1.0, 1.0) } else if m.contains("llama") || m.contains("mistral") {
7169 (0.1, 0.1)
7170 } else if m.contains("devstral") || m.contains("moe") {
7171 (0.05, 0.22) } else {
7173 (3.0, 15.0) };
7175
7176 let input_cost = (usage.prompt_tokens as f64 / 1_000_000.0) * input_rate;
7177 let output_cost = (usage.completion_tokens as f64 / 1_000_000.0) * output_rate;
7178 input_cost + output_cost
7179}
7180
7181#[cfg(test)]
7182mod tests {}