Skip to main content

eli_cli/
lib.rs

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
96/// Runtime session state (not persisted to config)
97struct 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        // Clear footer area before setting up scroll region
135        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        // Clear footer area before resetting scroll region
146        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        // Reset scroll region temporarily so we can write anywhere
156        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        // DECSTBM: set scroll region to exclude footer rows.
171        write!(out, "\x1b[1;{}r", bottom).ok();
172        // Keep cursor in scrollable region.
173        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            // 1. Reset scroll region so we can clear anywhere
189            write!(out, "\x1b[r").ok();
190
191            // 2. Save cursor, clear from old footer to end of screen using ED command
192            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            // Move to the earliest possible footer position and clear to end of screen
197            write!(out, "\x1b[{};1H", clear_from + 1).ok();  // Move to row
198            write!(out, "\x1b[J").ok();  // Clear from cursor to end of screen (ED0)
199            out.flush().ok();
200
201            // 3. Update dimensions and apply new scroll region
202            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        // TUI-style cursor rendering
211        let inner_width = width.saturating_sub(4).max(1); // Account for borders and prompt
212        let prompt = "› ";
213        let cursor_pos = cursor_pos.min(input.len());
214        let (before_cursor, after_cursor) = input.split_at(cursor_pos);
215
216        // Get character at cursor (or space if at end)
217        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        // Build styled line with block cursor
225        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        // Deduplicate by path and keep newest first.
343        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        // Show token usage hint if present
532        let tokens = self.last_input_tokens.load(std::sync::atomic::Ordering::Relaxed);
533        if tokens > 0 {
534            // "Input: ~X tokens"
535            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    /// Provider: openrouter | openai | anthropic | ollama | mock
679    #[arg(long, global = true)]
680    provider: Option<String>,
681
682    /// Model name (provider-specific)
683    #[arg(long, global = true)]
684    model: Option<String>,
685}
686
687#[derive(Subcommand, Debug)]
688enum Command {
689    /// Interactive setup - configure provider, model, and API key
690    Setup,
691
692    /// Create a default config file (if missing)
693    Init,
694
695    /// Print or set config values
696    Config {
697        /// Set a config value: provider, model, mem_steps, key, sec_user_agent, compact, compact_trigger, compact_keep, summary_model, parallel_commands, parallel_subagents, scrollback_max_lines
698        #[arg(long)]
699        set: Option<String>,
700
701        /// Value to set
702        #[arg(long)]
703        value: Option<String>,
704    },
705
706    /// Emit JSON schema for a CLI subcommand (hidden)
707    #[command(hide = true)]
708    ToolInfo {
709        /// Subcommand path (e.g., finance timeseries)
710        #[arg(value_name = "PATH", num_args = 0..)]
711        path: Vec<String>,
712    },
713
714    /// Chat in a readline loop (default)
715    Chat,
716
717    /// Chat in debug mode (raw request/response + full tool output + observation)
718    Debug,
719
720    /// Chat in raw mode (no extra dumps)
721    Raw,
722
723    /// One-shot quantitative research loop
724    Research {
725        /// Research question/prompt (quote it)
726        query: String,
727    },
728
729    /// Launch the (early) ratatui interface
730    Tui,
731
732    /// Financial data tools (for raw time-series exploration)
733    Finance {
734        #[command(subcommand)]
735        cmd: FinanceCommand,
736    },
737
738    /// Web tools (crawl, search, read)
739    Web {
740        #[command(subcommand)]
741        cmd: WebCommand,
742    },
743}
744
745#[derive(Subcommand, Debug)]
746enum FinanceCommand {
747    /// Fetch OHLCV time-series for one or more tickers.
748    Timeseries(FinanceTimeseriesArgs),
749    /// Fetch a point-in-time snapshot (market cap, shares, price, etc.) for one or more tickers.
750    Snapshot(FinanceSnapshotArgs),
751    /// Fetch quarterly financial statements (Income Statement, Balance Sheet, Cash Flow).
752    Fundamentals(FinanceFundamentalsArgs),
753    /// Search for ticker symbols or macro series IDs.
754    Search(FinanceSearchArgs),
755    /// Fetch recent SEC filings (8-K, 10-K, 10-Q) for a ticker.
756    Filings(FinanceFilingsArgs),
757    /// Alias for filings.
758    Sec(FinanceFilingsArgs),
759    /// Fetch news context for a specific ticker and date.
760    News(FinanceNewsArgs),
761    /// Fetch key macro economic indicators (CPI, Unemployment, GDP, etc).
762    Macro(FinanceMacroArgs),
763    /// Latest spot prices from Pyth Hermes (REST).
764    Prices(FinancePricesArgs),
765    /// Prediction market discovery + pricing (Kalshi default; falls back to Polymarket).
766    Odds(FinanceOddsArgs),
767    /// Listed options chains with IV/skew summaries (Yahoo Finance).
768    Options(FinanceOptionsArgs),
769}
770
771#[derive(Subcommand, Debug)]
772enum WebCommand {
773    /// Crawl a website and extract content from all discovered pages.
774    Crawl(WebCrawlArgs),
775    /// Search the web using DuckDuckGo.
776    Search(WebSearchArgs),
777    /// Read and extract content from a single URL.
778    Read(WebReadArgs),
779    /// Extract key facts from content (URL, file, or text).
780    Extract(WebExtractArgs),
781}
782
783#[derive(clap::Args, Debug)]
784struct WebCrawlArgs {
785    /// URL to start crawling from.
786    #[arg(long)]
787    url: String,
788
789    /// Maximum number of pages to crawl (default: 50).
790    #[arg(long, default_value = "50")]
791    max_pages: usize,
792
793    /// Respect robots.txt (default: true).
794    #[arg(long, default_value = "true")]
795    respect_robots: bool,
796
797    /// Include subdomains in crawl (default: false).
798    #[arg(long, default_value = "false")]
799    subdomains: bool,
800
801    /// Output file path (JSON).
802    #[arg(long)]
803    out: Option<PathBuf>,
804}
805
806#[derive(clap::Args, Debug)]
807struct WebSearchArgs {
808    /// Search query.
809    #[arg(long)]
810    query: String,
811
812    /// Output file path (JSON).
813    #[arg(long)]
814    out: Option<PathBuf>,
815}
816
817#[derive(clap::Args, Debug)]
818struct WebReadArgs {
819    /// URL to read content from.
820    #[arg(long)]
821    url: String,
822
823    /// Output file path (JSON).
824    #[arg(long)]
825    out: Option<PathBuf>,
826}
827
828#[derive(clap::Args, Debug)]
829struct WebExtractArgs {
830    /// URL to fetch and extract from.
831    #[arg(long)]
832    url: Option<String>,
833
834    /// File path to extract from.
835    #[arg(long)]
836    file: Option<PathBuf>,
837
838    /// Inline text to extract from (use heredoc for large content).
839    #[arg(long)]
840    text: Option<String>,
841
842    /// Number of bullet points to extract (default: 10).
843    #[arg(long, default_value = "10")]
844    bullets: usize,
845
846    /// Focus extraction on specific topic.
847    #[arg(long)]
848    focus: Option<String>,
849
850    /// Output file path (JSON).
851    #[arg(long)]
852    out: Option<PathBuf>,
853}
854
855#[derive(clap::Args, Debug)]
856pub struct FinanceMacroArgs {
857    /// Time range for calculating changes (e.g. 1y).
858    #[arg(long, default_value = "1y")]
859    pub range: String,
860    /// Output format (json only).
861    #[arg(long, default_value = "json")]
862    pub format: String,
863    /// Output file path.
864    #[arg(short, long)]
865    pub out: Option<PathBuf>,
866}
867
868
869#[derive(clap::Args, Debug)]
870struct FinanceNewsArgs {
871    /// Ticker to search for.
872    #[arg(long, visible_alias = "tickers")]
873    ticker: String,
874
875    /// Date of interest (YYYY-MM-DD).
876    #[arg(long)]
877    date: String,
878
879    /// Write full JSON output to a file instead of stdout.
880    #[arg(long)]
881    out: Option<PathBuf>,
882}
883
884#[derive(clap::Args, Debug)]
885struct FinanceSnapshotArgs {
886    /// Tickers to fetch (repeatable or comma-separated).
887    #[arg(long, visible_alias = "ticker", value_delimiter = ',')]
888    tickers: Vec<String>,
889
890    /// Optional file with tickers (one per line).
891    #[arg(long)]
892    tickers_file: Option<PathBuf>,
893
894    /// Data provider (mock | yahoo).
895    #[arg(long, default_value = "yahoo")]
896    provider: String,
897
898    /// Output format (currently: json).
899    #[arg(long, default_value = "json")]
900    format: String,
901
902    /// Write full JSON output to a file instead of stdout.
903    #[arg(long)]
904    out: Option<PathBuf>,
905}
906
907#[derive(clap::Args, Debug)]
908struct FinanceFundamentalsArgs {
909    /// Ticker to fetch fundamentals for.
910    #[arg(long, visible_alias = "tickers")]
911    ticker: String,
912
913    /// Output format (currently: json).
914    #[arg(long, default_value = "json")]
915    format: String,
916
917    /// Write full JSON output to a file instead of stdout.
918    #[arg(long)]
919    out: Option<PathBuf>,
920}
921
922#[derive(clap::Args, Debug)]
923struct FinanceSearchArgs {
924    /// Search query (e.g. "Apple" or "Inflation").
925    #[arg(long)]
926    query: String,
927
928    /// Output format (currently: json).
929    #[arg(long, default_value = "json")]
930    format: String,
931
932    /// Write full JSON output to a file instead of stdout.
933    #[arg(long)]
934    out: Option<PathBuf>,
935}
936
937#[derive(clap::Args, Debug)]
938struct FinancePricesArgs {
939    /// Discover price feeds by query (e.g. "pepe").
940    #[arg(long)]
941    query: Option<String>,
942
943    /// Asset type filter (e.g. crypto, equity, fx, metal, rates).
944    #[arg(long)]
945    asset_type: Option<String>,
946
947    /// Explicit Pyth price feed IDs (repeatable or comma-separated).
948    #[arg(long, value_delimiter = ',')]
949    ids: Vec<String>,
950
951    /// Output format (currently: json).
952    #[arg(long, default_value = "json")]
953    format: String,
954
955    /// Write full JSON output to a file instead of stdout.
956    #[arg(long)]
957    out: Option<PathBuf>,
958}
959
960#[derive(clap::Args, Debug)]
961struct FinanceOddsArgs {
962    /// Data source: kalshi (default), polymarket, or auto (kalshi then polymarket).
963    #[arg(long)]
964    provider: Option<String>,
965    /// Kalshi series ticker.
966    #[arg(long)]
967    series: Option<String>,
968
969    /// Event ticker.
970    #[arg(long)]
971    event: Option<String>,
972
973    /// Market ticker.
974    #[arg(long)]
975    market: Option<String>,
976
977    /// Filter by status (e.g. open).
978    #[arg(long)]
979    status: Option<String>,
980
981    /// Page size limit.
982    #[arg(long)]
983    limit: Option<usize>,
984
985    /// Pagination cursor.
986    #[arg(long)]
987    cursor: Option<String>,
988
989    /// Max pages to fetch (Kalshi list endpoints).
990    #[arg(long)]
991    max_pages: Option<usize>,
992
993    /// List series (Kalshi only).
994    #[arg(long)]
995    list_series: bool,
996
997    /// List events.
998    #[arg(long)]
999    list_events: bool,
1000
1001    /// List markets.
1002    #[arg(long)]
1003    list_markets: bool,
1004
1005    /// List tags (Polymarket only).
1006    #[arg(long)]
1007    list_tags: bool,
1008
1009    /// Category filter (Kalshi list endpoints).
1010    #[arg(long)]
1011    category: Option<String>,
1012
1013    /// Case-insensitive literal substring match (titles/tickers/slugs).
1014    #[arg(long)]
1015    search: Option<String>,
1016
1017    /// Include orderbook depth (heavier call; Polymarket orderbook supported).
1018    #[arg(long)]
1019    orderbook: bool,
1020
1021    /// Orderbook depth (levels).
1022    #[arg(long)]
1023    depth: Option<usize>,
1024
1025    /// Output format (currently: json).
1026    #[arg(long, default_value = "json")]
1027    format: String,
1028
1029    /// Write full JSON output to a file instead of stdout.
1030    #[arg(long)]
1031    out: Option<PathBuf>,
1032}
1033
1034#[derive(clap::Args, Debug)]
1035struct FinanceOptionsArgs {
1036    /// Underlying ticker (e.g. INTC).
1037    #[arg(long, visible_alias = "tickers")]
1038    ticker: String,
1039
1040    /// Expiration date (YYYY-MM-DD). If omitted, uses the first available expiry.
1041    #[arg(long)]
1042    expiry: Option<String>,
1043
1044    /// Filter: calls | puts | both (default: both).
1045    #[arg(long = "type", value_name = "calls|puts|both")]
1046    option_type: Option<String>,
1047
1048    /// Only return strikes within this percentage of the underlying (e.g. 10 = +/-10%).
1049    #[arg(long = "near-money")]
1050    near_money: Option<f64>,
1051
1052    /// Return summary metrics only (no full chain).
1053    #[arg(long)]
1054    summary: bool,
1055
1056    /// List available expirations only.
1057    #[arg(long)]
1058    expirations: bool,
1059
1060    /// Output format (currently: json).
1061    #[arg(long, default_value = "json")]
1062    format: String,
1063
1064    /// Write full JSON output to a file instead of stdout.
1065    #[arg(long)]
1066    out: Option<PathBuf>,
1067}
1068
1069#[derive(clap::Args, Debug)]
1070struct FinanceFilingsArgs {
1071    /// Ticker to fetch filings for.
1072    #[arg(long, visible_alias = "tickers")]
1073    ticker: String,
1074
1075    /// Form types to include (comma-separated), e.g. 8-K,10-K,10-Q. Defaults to 8-K,10-K,10-Q.
1076    #[arg(long, value_delimiter = ',')]
1077    forms: Vec<String>,
1078
1079    /// Max number of filings to return.
1080    #[arg(long, default_value_t = 5)]
1081    limit: usize,
1082
1083    /// Download primary documents, save to cache, and include a text excerpt inline.
1084    #[arg(long)]
1085    include_text: bool,
1086
1087    /// Max chars for the inline excerpt (full text is still written to disk when --include-text is set).
1088    #[arg(long)]
1089    max_chars: Option<usize>,
1090
1091    /// Override cache directory (defaults to Eli's cache dir).
1092    #[arg(long)]
1093    cache_dir: Option<PathBuf>,
1094
1095    /// Output format (currently: json).
1096    #[arg(long, default_value = "json")]
1097    format: String,
1098
1099    /// Write full JSON output to a file instead of stdout.
1100    #[arg(long)]
1101    out: Option<PathBuf>,
1102}
1103
1104#[derive(clap::Args, Debug)]
1105struct FinanceTimeseriesArgs {
1106    /// Tickers to fetch (repeatable or comma-separated).
1107    #[arg(long, visible_alias = "ticker", value_delimiter = ',')]
1108    tickers: Vec<String>,
1109
1110    /// Optional file with tickers (one per line).
1111    #[arg(long)]
1112    tickers_file: Option<PathBuf>,
1113
1114    /// Lookback range (e.g. 1d, 12mo, 5y).
1115    #[arg(long, default_value = "1y")]
1116    range: String,
1117
1118    /// Candle size / sampling granularity (e.g. 10m, 1h, 1d, 1w, 1mo).
1119    #[arg(long, default_value = "1d")]
1120    granularity: String,
1121
1122    /// End timestamp for the window (RFC3339). If you pass YYYY-MM-DD, it's treated as end-of-day UTC. Defaults to now (UTC).
1123    #[arg(long)]
1124    as_of: Option<String>,
1125
1126    /// Data provider (mock | yahoo | fred).
1127    #[arg(long, default_value = "yahoo")]
1128    provider: String,
1129
1130    /// Safety cap for points per ticker.
1131    #[arg(long)]
1132    max_points_per_ticker: Option<usize>,
1133
1134    /// Override cache directory (defaults to Eli's cache dir).
1135    #[arg(long)]
1136    cache_dir: Option<PathBuf>,
1137
1138    /// Output format (currently: json).
1139    #[arg(long, default_value = "json")]
1140    format: String,
1141
1142    /// Write full JSON output to a file instead of stdout.
1143    #[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    // Research defaults: safe, autonomous, non-destructive.
1178    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    // Ensure instincts directory exists
1206    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    // Inject existing instincts into memory
1217    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
1412/// Redirect JSON output files to eli_research/data/ if they're in the project root.
1413fn redirect_finance_output(path: std::path::PathBuf) -> std::path::PathBuf {
1414    // Only redirect if it's a bare filename (no directory component)
1415    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            // Ensure directory exists
1419            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    // Provider selection
1898    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    // Model selection with smart defaults
1921    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    // API key (skip for Ollama)
1941    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                _ => {} // Should not happen
1954            }
1955        }
1956    }
1957
1958    // Save config
1959    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 setting a value
1982    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                    _ => {} // Should not happen
2008                }
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    // Otherwise, print current config
2065    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
2209/// Run chat in TUI mode (alternate screen, no ghost issues)
2210async 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    // Map TUI prompt mode to config
2231    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        // Update spinner and elapsed time
2242        ui.tick_spinner();
2243        ui.elapsed_secs = task_start.elapsed().as_secs();
2244
2245        // Render
2246        terminal.draw(&mut ui)?;
2247
2248        // Poll for events
2249        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                            // Handle slash commands
2261                            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                                // Can't switch rendering modes mid-session
2330                                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                                // Reset memory by creating fresh one with same system prompt
2361                                *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                            // Regular input - run agent
2372                            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                            // Render processing state
2390                        terminal.draw(&mut ui)?;
2391
2392                        let (clean_prompt, images) = process_input_for_images(trimmed);
2393
2394                        // Run the agent (single unified persona)
2395                        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
2504/// Run agent steps with TUI output
2505async 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        // Update UI
2533        ui.tick_spinner();
2534        terminal.draw(ui)?;
2535
2536        // Add message to memory
2537        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        // Build request
2545        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        // Stream response (spinner in title shows we're working)
2555        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            // Update UI periodically
2634            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        // Parse response
2656        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        // Add response to memory
2676        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        // Show the answer/notes
2691        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        // Execute commands if any
2700        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                    // Build full tool output for memory (LLM needs the actual data!)
2746                    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                    // Infer sources (never invent a generic "eli finance" source)
2758                    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            // CRITICAL: Feed tool results back to memory so LLM can use the actual values!
2767            if !all_tool_output.is_empty() {
2768                memory.push(ChatMessage::user(format!("Tool execution results:\n{}", all_tool_output)));
2769            }
2770        }
2771
2772        // Check if done
2773        if matches!(model.status, StepStatus::Done) {
2774            break;
2775        }
2776
2777        // Continue with KEEP WORKING
2778        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    // Use TUI mode for Standard display mode (alternate screen, no ghost issues)
2860    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    // Non-TUI modes (Brain/Debug/Raw) use the old CLI approach
2876    print_banner(&cfg.chat, &project_root, &state);
2877
2878    loop {
2879        // Show queue status if there are queued prompts
2880        let queue_len = state.prompt_queue.len();
2881
2882        // Update token hint for the upcoming prompt
2883        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        // Slash commands
2933        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        // Queue prompt with + prefix (e.g., "+fix the bug" queues it)
3127        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        // Process images
3137        let (clean_prompt, images) = process_input_for_images(trimmed);
3138        // Run agent for this prompt (single unified persona)
3139        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        // Process queue automatically
3158        while let Some(queued_prompt) = state.next_prompt() {
3159            print_history_line(format!("{}›{} {}", style::CYAN, style::RESET, queued_prompt));
3160            // Queue currently supports text only (no image dragging into queue command yet, 
3161            // though one could theoretically type the path, but process_input_for_images handles paths in string)
3162            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(); // Cursor at end after history load
3321                        }
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(); // Cursor at end after history load
3337                        }
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        // Ensure a pinned instructions section exists (like CLAUDE.md), without clobbering existing notes.
3501        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
3651/// Execute /copy command - query session state and copy to clipboard or file
3652async 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    // Parse arguments
3660    let parts: Vec<&str> = args.split_whitespace().collect();
3661
3662    // Check for file output: /copy all > file.md
3663    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    // Parse scope
3672    let scope = scope_parts.first().copied().unwrap_or("");
3673
3674    // Check for filters (-data, -raw, -meta)
3675    let exclude_data = scope_parts.iter().any(|&p| p == "-data");
3676    let exclude_meta = scope_parts.iter().any(|&p| p == "-meta");
3677
3678    // Get messages from memory
3679    let messages = memory.context();
3680
3681    // Filter by scope
3682    let filtered: Vec<_> = match scope {
3683        "" | "last" => {
3684            // Last assistant response
3685            messages.iter()
3686                .rev()
3687                .find(|m| m.role == Role::Assistant)
3688                .into_iter()
3689                .collect()
3690        }
3691        "all" => {
3692            // All non-system messages
3693            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            // Last N turns (user + assistant pairs)
3714            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    // Format as markdown
3730    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, // Skip system messages
3737        };
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    // Output to file or clipboard
3755    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        // Copy to clipboard
3762        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; // Consumed as image
3995                    }
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        // Sequence fix: only push "KEEP WORKING" if the last message wasn't a tool observation.
4046        // This avoids double-user messages which crash some providers.
4047        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                // Clear images after first use so we don't re-send them in loop unless intended
4069                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        // Track step time
4387        let step_elapsed = step_start.elapsed();
4388
4389        // Print step summary (brief vs full)
4390        if brief {
4391            if step == 1 {
4392                // Force a scroll line so the first prompt is not overwritten.
4393                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                    // READ mode: allow ONLY creation of NEW files.
4502                    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                    // READ mode: allow all commands as requested by user
4537                    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        // Capture trajectory
4839        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	                        // Only show synthesis box if there's substantial content beyond the step summary
4866	                        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	                        // Skip print_answer_line - step summary already showed the answer
4873	                    }
4874	                    // Skip print_answer_line for notes - step summary already showed them
4875	                }
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	                // Show final summary for brief mode
4950	                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    // ASCII art logo with monochrome gradient (white → gray)
5069    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", // bright white
5079        W2 = "\x1b[38;5;252m", // light gray
5080        W3 = "\x1b[38;5;249m", // medium light
5081        W4 = "\x1b[38;5;246m", // medium gray
5082        W5 = "\x1b[38;5;243m", // darker gray
5083        W6 = "\x1b[38;5;240m", // dark gray
5084    );
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
5148/// Brief step summary for standard mode - one line
5149fn print_step_summary_brief(_step: u32, elapsed: Duration, model: &eli_core::contract::ModelResponse) {
5150    let _ = elapsed;
5151    match model.status {
5152        StepStatus::KeepWorking => {
5153            // Show focus/plan when still working
5154            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            // Show the actual response/answer unboxed
5169            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    // Header removed as per user request ("eli" name gone)
5312    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        // Ensure answer lines are formatted nicely? 
5336        // Just push it, format_indented_block handles wrapping.
5337        // We use a bullet for the answer block? Or just text?
5338        // Logic before was: format!("{}◆{} {}", CYAN, RESET, answer)
5339        // User wants unboxed.
5340        // If it's multi-line, prefixing with ◆ might look odd if not handled.
5341        // format_indented_block handles bullet indentation.
5342        
5343        // Let's just push lines, maybe with a bullet for the first line?
5344        // Or "◆ " prefix.
5345        
5346        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// ═══════════════════════════════════════════════════════════════════════════════
5444// STYLING CONSTANTS
5445// ═══════════════════════════════════════════════════════════════════════════════
5446
5447#[allow(dead_code)]
5448mod style {
5449    // Box drawing chars (rounded)
5450    pub const TL: &str = "╭";  // top-left
5451    pub const TR: &str = "╮";  // top-right
5452    pub const BL: &str = "╰";  // bottom-left
5453    pub const BR: &str = "╯";  // bottom-right
5454    pub const H: &str = "─";   // horizontal
5455    pub const V: &str = "│";   // vertical
5456
5457    // Colors (ANSI 256 / RGB where supported)
5458    pub const RESET: &str = "\x1b[0m";
5459    pub const BOLD: &str = "\x1b[1m";
5460    pub const DIM: &str = "\x1b[2m";
5461
5462    // Gradient palette for eli branding
5463    pub const CYAN: &str = "\x1b[38;5;51m";       // bright cyan
5464    pub const BLUE: &str = "\x1b[38;5;39m";       // bright blue
5465    pub const PURPLE: &str = "\x1b[38;5;141m";    // lavender
5466    pub const PINK: &str = "\x1b[38;5;213m";      // pink
5467    pub const GREEN: &str = "\x1b[38;5;120m";     // mint green
5468    pub const YELLOW: &str = "\x1b[38;5;227m";    // soft yellow
5469    pub const ORANGE: &str = "\x1b[38;5;215m";    // peach
5470    pub const RED: &str = "\x1b[38;5;203m";       // coral red
5471    pub const GRAY: &str = "\x1b[38;5;245m";      // medium gray
5472    pub const DARK_GRAY: &str = "\x1b[38;5;238m"; // dark gray
5473    pub const WHITE: &str = "\x1b[38;5;255m";     // bright white
5474
5475    // Semantic colors
5476    pub const SUCCESS: &str = "\x1b[38;5;120m";   // mint
5477    pub const ERROR: &str = "\x1b[38;5;203m";     // coral
5478    pub const WARN: &str = "\x1b[38;5;215m";      // peach
5479    pub const INFO: &str = "\x1b[38;5;111m";      // soft blue
5480    pub const MUTED: &str = "\x1b[38;5;245m";     // gray
5481
5482    // Spinner frames handled by indicatif (no manual frames here).
5483}
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
5899/// Strip ANSI escape sequences for length calculation
5900fn 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        // Escape sequence.
5910        match it.next() {
5911            Some('[') => {
5912                // CSI: ESC [ ... <final byte>
5913                while let Some(ch) = it.next() {
5914                    if ('@'..='~').contains(&ch) {
5915                        break;
5916                    }
5917                }
5918            }
5919            Some(']') => {
5920                // OSC: ESC ] ... BEL | ESC \
5921                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    // Keep the contract/system prompt first, but insert this near the top
6703    // (after any initial system messages like date/summary/brain).
6704    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    // Titles are at the top for Eli reports; keep this cheap.
6792    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    // Very rough estimation based on common OpenRouter/Anthropic pricing
7138    // Normalize model name
7139    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) // Approx OpenRouter pricing for huge models
7168    } 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) // $0.22 per 1M output tokens as requested
7172    } else {
7173        (3.0, 15.0) // Default to Sonnet
7174    };
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 {}