toon_format/tui/
app.rs

1use std::{
2    fs,
3    path::PathBuf,
4    time::Duration,
5};
6
7use anyhow::{
8    Context,
9    Result,
10};
11use chrono::Local;
12use crossterm::event::{
13    KeyCode,
14    KeyEvent,
15};
16use tiktoken_rs::cl100k_base;
17
18use crate::{
19    decode,
20    encode,
21    tui::{
22        components::FileBrowser,
23        events::{
24            Event,
25            EventHandler,
26        },
27        keybindings::{
28            Action,
29            KeyBindings,
30        },
31        repl_command::ReplCommand,
32        state::{
33            app_state::ConversionStats,
34            AppState,
35            ConversionHistory,
36        },
37        ui,
38    },
39};
40
41/// Main TUI application managing state, events, and rendering.
42pub struct TuiApp<'a> {
43    pub app_state: AppState<'a>,
44    pub file_browser: FileBrowser,
45}
46
47impl<'a> TuiApp<'a> {
48    pub fn new() -> Self {
49        Self {
50            app_state: AppState::new(),
51            file_browser: FileBrowser::new(),
52        }
53    }
54
55    pub fn run<B: ratatui::backend::Backend>(
56        &mut self,
57        terminal: &mut ratatui::Terminal<B>,
58    ) -> Result<()> {
59        loop {
60            terminal.draw(|f| ui::render(f, &mut self.app_state, &mut self.file_browser))?;
61
62            if let Some(event) = EventHandler::poll(Duration::from_millis(100))? {
63                self.handle_event(event)?;
64            }
65
66            if self.app_state.should_quit {
67                break;
68            }
69        }
70        Ok(())
71    }
72
73    fn handle_event(&mut self, event: Event) -> Result<()> {
74        match event {
75            Event::Key(key) => self.handle_key_event(key)?,
76            Event::Resize => {}
77            Event::Tick => {}
78        }
79        Ok(())
80    }
81
82    fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
83        // REPL takes priority when active
84        if self.app_state.repl.active {
85            return self.handle_repl_key(key);
86        }
87
88        // Handle overlay panels (help, file browser, settings, etc.)
89        if self.app_state.show_help
90            || self.app_state.show_file_browser
91            || self.app_state.show_history
92            || self.app_state.show_diff
93            || self.app_state.show_settings
94        {
95            match key.code {
96                KeyCode::Esc => {
97                    self.app_state.show_help = false;
98                    self.app_state.show_file_browser = false;
99                    self.app_state.show_history = false;
100                    self.app_state.show_diff = false;
101                    self.app_state.show_settings = false;
102                    return Ok(());
103                }
104                KeyCode::F(1) if self.app_state.show_help => {
105                    self.app_state.show_help = false;
106                    return Ok(());
107                }
108                _ => {}
109            }
110
111            if self.app_state.show_file_browser {
112                match key.code {
113                    KeyCode::Up => {
114                        self.file_browser.move_up();
115                        return Ok(());
116                    }
117                    KeyCode::Down => {
118                        let count = self
119                            .file_browser
120                            .get_entry_count(&self.app_state.file_state.current_dir);
121                        self.file_browser.move_down(count);
122                        return Ok(());
123                    }
124                    KeyCode::Enter => {
125                        self.handle_file_selection()?;
126                        return Ok(());
127                    }
128                    KeyCode::Char(' ') => {
129                        self.handle_file_toggle_selection()?;
130                        return Ok(());
131                    }
132                    _ => {}
133                }
134            }
135
136            if self.app_state.show_settings {
137                match key.code {
138                    KeyCode::Esc => {
139                        self.app_state.show_settings = false;
140                        return Ok(());
141                    }
142                    KeyCode::Char('d') => {
143                        self.app_state.cycle_delimiter();
144                        self.perform_conversion();
145                        return Ok(());
146                    }
147                    KeyCode::Char('+') | KeyCode::Char('=') => {
148                        self.app_state.increase_indent();
149                        self.perform_conversion();
150                        return Ok(());
151                    }
152                    KeyCode::Char('-') | KeyCode::Char('_') => {
153                        self.app_state.decrease_indent();
154                        self.perform_conversion();
155                        return Ok(());
156                    }
157                    KeyCode::Char('f') => {
158                        self.app_state.toggle_fold_keys();
159                        self.perform_conversion();
160                        return Ok(());
161                    }
162                    KeyCode::Char('p') => {
163                        self.app_state.toggle_expand_paths();
164                        self.perform_conversion();
165                        return Ok(());
166                    }
167                    KeyCode::Char('s') => {
168                        self.app_state.toggle_strict();
169                        self.perform_conversion();
170                        return Ok(());
171                    }
172                    KeyCode::Char('c') => {
173                        self.app_state.toggle_coerce_types();
174                        self.perform_conversion();
175                        return Ok(());
176                    }
177                    KeyCode::Char('[') | KeyCode::Char('{') => {
178                        self.app_state.decrease_flatten_depth();
179                        self.perform_conversion();
180                        return Ok(());
181                    }
182                    KeyCode::Char(']') | KeyCode::Char('}') => {
183                        self.app_state.increase_flatten_depth();
184                        self.perform_conversion();
185                        return Ok(());
186                    }
187                    KeyCode::Char('u') => {
188                        self.app_state.toggle_flatten_depth();
189                        self.perform_conversion();
190                        return Ok(());
191                    }
192                    _ => {}
193                }
194            }
195        }
196
197        let action = KeyBindings::handle(key);
198        match action {
199            Action::Quit => self.app_state.quit(),
200            Action::ToggleMode => {
201                self.app_state.toggle_mode();
202                self.perform_conversion();
203            }
204            Action::SwitchPanel => {
205                self.app_state.editor.toggle_active();
206            }
207            Action::OpenFile => {
208                self.open_file_dialog()?;
209            }
210            Action::SaveFile => {
211                self.save_output()?;
212            }
213            Action::NewFile => {
214                self.new_file();
215            }
216            Action::Refresh => {
217                self.perform_conversion();
218            }
219            Action::ToggleSettings => {
220                self.app_state.toggle_settings();
221            }
222            Action::ToggleHelp => {
223                self.app_state.toggle_help();
224            }
225            Action::ToggleFileBrowser => {
226                self.app_state.toggle_file_browser();
227            }
228            Action::ToggleHistory => {
229                self.app_state.toggle_history();
230            }
231            Action::ToggleDiff => {
232                self.app_state.toggle_diff();
233            }
234            Action::ToggleTheme => {
235                self.app_state.toggle_theme();
236            }
237            Action::CopyOutput => {
238                self.copy_to_clipboard()?;
239            }
240            Action::OpenRepl => {
241                self.app_state.repl.activate();
242            }
243            Action::CopySelection => {
244                self.copy_selection_to_clipboard()?;
245            }
246            Action::PasteInput => {
247                self.paste_from_clipboard()?;
248            }
249            Action::RoundTrip => {
250                self.perform_round_trip()?;
251            }
252            Action::ClearInput => {
253                self.app_state.editor.clear_input();
254                self.app_state.editor.clear_output();
255                self.app_state.stats = None;
256            }
257            Action::None => {
258                if self.app_state.editor.is_input_active() {
259                    self.app_state.editor.input.input(key);
260                    self.app_state.file_state.mark_modified();
261                    self.perform_conversion();
262                } else if self.app_state.editor.is_output_active() {
263                    // Output is read-only, only allow navigation
264                    match key.code {
265                        KeyCode::Up
266                        | KeyCode::Down
267                        | KeyCode::Left
268                        | KeyCode::Right
269                        | KeyCode::PageUp
270                        | KeyCode::PageDown
271                        | KeyCode::Home
272                        | KeyCode::End => {
273                            self.app_state.editor.output.input(key);
274                        }
275                        _ => {}
276                    }
277                }
278            }
279        }
280
281        Ok(())
282    }
283
284    /// Convert input based on current mode (encode/decode).
285    fn perform_conversion(&mut self) {
286        let input = self.app_state.editor.get_input();
287        if input.trim().is_empty() {
288            self.app_state.editor.clear_output();
289            self.app_state.stats = None;
290            self.app_state.clear_error();
291            return;
292        }
293
294        self.app_state.clear_error();
295
296        match self.app_state.mode {
297            crate::tui::state::app_state::Mode::Encode => {
298                self.encode_input(&input);
299            }
300            crate::tui::state::app_state::Mode::Decode => {
301                self.decode_input(&input);
302            }
303        }
304    }
305
306    fn encode_input(&mut self, input: &str) {
307        self.app_state.editor.clear_output();
308
309        match serde_json::from_str::<serde_json::Value>(input) {
310            Ok(json_value) => match encode(&json_value, &self.app_state.encode_options) {
311                Ok(toon_str) => {
312                    self.app_state.editor.set_output(toon_str.clone());
313                    self.app_state.clear_error();
314
315                    if let Ok(bpe) = cl100k_base() {
316                        let json_tokens = bpe.encode_with_special_tokens(input).len();
317                        let toon_tokens = bpe.encode_with_special_tokens(&toon_str).len();
318                        let json_bytes = input.len();
319                        let toon_bytes = toon_str.len();
320
321                        let token_savings =
322                            100.0 * (1.0 - (toon_tokens as f64 / json_tokens as f64));
323                        let byte_savings = 100.0 * (1.0 - (toon_bytes as f64 / json_bytes as f64));
324
325                        self.app_state.stats = Some(ConversionStats {
326                            json_tokens,
327                            toon_tokens,
328                            json_bytes,
329                            toon_bytes,
330                            token_savings,
331                            byte_savings,
332                        });
333
334                        self.app_state.file_state.add_to_history(ConversionHistory {
335                            timestamp: Local::now(),
336                            mode: "Encode".to_string(),
337                            input_file: self.app_state.file_state.current_file.clone(),
338                            output_file: None,
339                            token_savings,
340                            byte_savings,
341                        });
342                    }
343                }
344                Err(e) => {
345                    self.app_state.set_error(format!("Encode error: {e}"));
346                }
347            },
348            Err(e) => {
349                self.app_state.set_error(format!("Invalid JSON: {e}"));
350            }
351        }
352    }
353
354    fn decode_input(&mut self, input: &str) {
355        self.app_state.editor.clear_output();
356
357        match decode::<serde_json::Value>(input, &self.app_state.decode_options) {
358            Ok(json_value) => match serde_json::to_string_pretty(&json_value) {
359                Ok(json_str) => {
360                    self.app_state.editor.set_output(json_str.clone());
361                    self.app_state.clear_error();
362
363                    if let Ok(bpe) = cl100k_base() {
364                        let toon_tokens = bpe.encode_with_special_tokens(input).len();
365                        let json_tokens = bpe.encode_with_special_tokens(&json_str).len();
366                        let toon_bytes = input.len();
367                        let json_bytes = json_str.len();
368
369                        let token_savings =
370                            100.0 * (1.0 - (toon_tokens as f64 / json_tokens as f64));
371                        let byte_savings = 100.0 * (1.0 - (toon_bytes as f64 / json_bytes as f64));
372
373                        self.app_state.stats = Some(ConversionStats {
374                            json_tokens,
375                            toon_tokens,
376                            json_bytes,
377                            toon_bytes,
378                            token_savings,
379                            byte_savings,
380                        });
381
382                        self.app_state.file_state.add_to_history(ConversionHistory {
383                            timestamp: Local::now(),
384                            mode: "Decode".to_string(),
385                            input_file: self.app_state.file_state.current_file.clone(),
386                            output_file: None,
387                            token_savings,
388                            byte_savings,
389                        });
390                    }
391                }
392                Err(e) => {
393                    self.app_state
394                        .set_error(format!("JSON serialization error: {e}"));
395                }
396            },
397            Err(e) => {
398                self.app_state.set_error(format!("Decode error: {e}"));
399            }
400        }
401    }
402
403    fn open_file_dialog(&mut self) -> Result<()> {
404        self.app_state.toggle_file_browser();
405        Ok(())
406    }
407
408    fn save_output(&mut self) -> Result<()> {
409        let output = self.app_state.editor.get_output();
410        if output.trim().is_empty() {
411            self.app_state.set_error("Nothing to save".to_string());
412            return Ok(());
413        }
414
415        let extension = match self.app_state.mode {
416            crate::tui::state::app_state::Mode::Encode => "toon",
417            crate::tui::state::app_state::Mode::Decode => "json",
418        };
419
420        let path = if let Some(current) = &self.app_state.file_state.current_file {
421            current.with_extension(extension)
422        } else {
423            PathBuf::from(format!("output.{extension}"))
424        };
425
426        fs::write(&path, output).context("Failed to save file")?;
427        self.app_state
428            .set_status(format!("Saved to {}", path.display()));
429        self.app_state.file_state.is_modified = false;
430
431        Ok(())
432    }
433
434    fn new_file(&mut self) {
435        if self.app_state.file_state.is_modified {
436            // TODO: confirmation dialog
437        }
438        self.app_state.editor.clear_input();
439        self.app_state.editor.clear_output();
440        self.app_state.file_state.clear_current_file();
441        self.app_state.stats = None;
442        self.app_state.set_status("New file created".to_string());
443    }
444
445    fn copy_to_clipboard(&mut self) -> Result<()> {
446        let output = self.app_state.editor.get_output();
447        if output.trim().is_empty() {
448            self.app_state.set_error("Nothing to copy".to_string());
449            return Ok(());
450        }
451
452        #[cfg(not(target_os = "unknown"))]
453        {
454            use arboard::Clipboard;
455            let mut clipboard = Clipboard::new()?;
456            clipboard.set_text(output)?;
457            self.app_state.set_status("Copied to clipboard".to_string());
458        }
459
460        #[cfg(target_os = "unknown")]
461        {
462            self.app_state
463                .set_error("Clipboard not supported on this platform".to_string());
464        }
465
466        Ok(())
467    }
468
469    fn paste_from_clipboard(&mut self) -> Result<()> {
470        #[cfg(not(target_os = "unknown"))]
471        {
472            use arboard::Clipboard;
473            let mut clipboard = Clipboard::new()?;
474            let text = clipboard.get_text()?;
475            self.app_state.editor.set_input(text);
476            self.app_state.file_state.mark_modified();
477            self.perform_conversion();
478            self.app_state
479                .set_status("Pasted from clipboard".to_string());
480        }
481
482        #[cfg(target_os = "unknown")]
483        {
484            self.app_state
485                .set_error("Clipboard not supported on this platform".to_string());
486        }
487
488        Ok(())
489    }
490
491    fn handle_file_selection(&mut self) -> Result<()> {
492        let current_dir = self.app_state.file_state.current_dir.clone();
493        if let Some(selected_path) = self.file_browser.get_selected_entry(&current_dir) {
494            if selected_path.is_dir() {
495                // Navigate into directory
496                self.app_state.file_state.current_dir = selected_path;
497                self.file_browser.selected_index = 0;
498                self.app_state.set_status(format!(
499                    "Navigated to {}",
500                    self.app_state.file_state.current_dir.display()
501                ));
502            } else if selected_path.is_file() {
503                // Open file
504                match fs::read_to_string(&selected_path) {
505                    Ok(content) => {
506                        self.app_state.editor.set_input(content);
507                        self.app_state
508                            .file_state
509                            .set_current_file(selected_path.clone());
510
511                        // Auto-detect mode based on extension
512                        if let Some(ext) = selected_path.extension().and_then(|e| e.to_str()) {
513                            match ext {
514                                "json" => {
515                                    self.app_state.mode =
516                                        crate::tui::state::app_state::Mode::Encode;
517                                }
518                                "toon" => {
519                                    self.app_state.mode =
520                                        crate::tui::state::app_state::Mode::Decode;
521                                }
522                                _ => {}
523                            }
524                        }
525
526                        self.perform_conversion();
527                        self.app_state.show_file_browser = false;
528                        self.app_state
529                            .set_status(format!("Opened {}", selected_path.display()));
530                    }
531                    Err(e) => {
532                        self.app_state
533                            .set_error(format!("Failed to read file: {e}"));
534                    }
535                }
536            }
537        }
538        Ok(())
539    }
540
541    fn handle_file_toggle_selection(&mut self) -> Result<()> {
542        let current_dir = self.app_state.file_state.current_dir.clone();
543        if let Some(selected_path) = self.file_browser.get_selected_entry(&current_dir) {
544            if selected_path.is_file() {
545                self.app_state
546                    .file_state
547                    .toggle_file_selection(selected_path.clone());
548                let is_selected = self.app_state.file_state.is_selected(&selected_path);
549                let action = if is_selected {
550                    "Selected"
551                } else {
552                    "Deselected"
553                };
554                self.app_state
555                    .set_status(format!("{} {}", action, selected_path.display()));
556            }
557        }
558        Ok(())
559    }
560
561    fn copy_selection_to_clipboard(&mut self) -> Result<()> {
562        let text = if self.app_state.editor.is_input_active() {
563            self.app_state.editor.input.yank_text()
564        } else {
565            self.app_state.editor.output.yank_text()
566        };
567
568        if text.is_empty() {
569            self.app_state.set_error("Nothing to copy".to_string());
570            return Ok(());
571        }
572
573        #[cfg(not(target_os = "unknown"))]
574        {
575            use arboard::Clipboard;
576            let mut clipboard = Clipboard::new()?;
577            clipboard.set_text(text)?;
578            self.app_state
579                .set_status("Copied selection to clipboard".to_string());
580        }
581
582        #[cfg(target_os = "unknown")]
583        {
584            self.app_state
585                .set_error("Clipboard not supported on this platform".to_string());
586        }
587
588        Ok(())
589    }
590
591    /// Round-trip test: convert output back to input and verify.
592    fn perform_round_trip(&mut self) -> Result<()> {
593        let output = self.app_state.editor.get_output();
594        if output.trim().is_empty() {
595            self.app_state
596                .set_error("No output to round-trip test. Convert something first!".to_string());
597            return Ok(());
598        }
599
600        let original_input = self.app_state.editor.get_input();
601        self.app_state.editor.set_input(output.clone());
602        self.app_state.toggle_mode();
603        self.perform_conversion();
604
605        let roundtrip_output = self.app_state.editor.get_output();
606
607        if roundtrip_output.trim().is_empty() {
608            self.app_state.set_error(
609                "Round-trip failed! Conversion produced no output. Check for errors.".to_string(),
610            );
611            return Ok(());
612        }
613
614        let matches = self.compare_data(&original_input, &roundtrip_output);
615
616        if matches {
617            self.app_state
618                .set_status("✓ Round-trip successful! Output matches original.".to_string());
619        } else {
620            self.app_state.set_error(format!(
621                "⚠ Round-trip mismatch! Original had {} chars, round-trip has {} chars.",
622                original_input.len(),
623                roundtrip_output.len()
624            ));
625        }
626
627        Ok(())
628    }
629
630    /// Compare data semantically, trying JSON parse first.
631    fn compare_data(&self, original: &str, roundtrip: &str) -> bool {
632        // Try JSON comparison for accuracy
633        if let (Ok(orig_json), Ok(rt_json)) = (
634            serde_json::from_str::<serde_json::Value>(original),
635            serde_json::from_str::<serde_json::Value>(roundtrip),
636        ) {
637            return orig_json == rt_json;
638        }
639
640        let original_normalized: String = original.split_whitespace().collect();
641        let roundtrip_normalized: String = roundtrip.split_whitespace().collect();
642        original_normalized == roundtrip_normalized
643    }
644
645    /// Handle keyboard input when REPL is active.
646    fn handle_repl_key(&mut self, key: KeyEvent) -> Result<()> {
647        match key.code {
648            KeyCode::Esc => {
649                self.app_state.repl.deactivate();
650            }
651            KeyCode::Char('r')
652                if key
653                    .modifiers
654                    .contains(crossterm::event::KeyModifiers::CONTROL) =>
655            {
656                self.app_state.repl.deactivate();
657            }
658            KeyCode::Enter => {
659                let cmd_input = self.app_state.repl.input.clone();
660                if !cmd_input.trim().is_empty() {
661                    self.app_state.repl.add_prompt(&cmd_input);
662                    self.app_state.repl.add_to_history(cmd_input.clone());
663
664                    if let Err(e) = self.execute_repl_command(&cmd_input) {
665                        self.app_state.repl.add_error(format!("{e}"));
666                    }
667
668                    self.app_state.repl.input.clear();
669                    self.app_state.repl.scroll_to_bottom();
670                }
671            }
672            KeyCode::Up => {
673                self.app_state.repl.history_up();
674            }
675            KeyCode::Down => {
676                self.app_state.repl.history_down();
677            }
678            KeyCode::PageUp => {
679                self.app_state.repl.scroll_up();
680            }
681            KeyCode::PageDown => {
682                self.app_state.repl.scroll_down(20);
683            }
684            KeyCode::Char(c) => {
685                self.app_state.repl.input.push(c);
686            }
687            KeyCode::Backspace => {
688                self.app_state.repl.input.pop();
689            }
690            _ => {}
691        }
692        Ok(())
693    }
694
695    /// Execute parsed REPL command and update state.
696    fn execute_repl_command(&mut self, input: &str) -> Result<()> {
697        let cmd = ReplCommand::parse(input)?;
698
699        match cmd.name.as_str() {
700            "encode" | "e" => {
701                let mut data = cmd
702                    .inline_data
703                    .as_ref()
704                    .map(|s| s.to_string())
705                    .unwrap_or_else(String::new);
706
707                data = self.substitute_variables(&data);
708
709                if data.is_empty() {
710                    self.app_state
711                        .repl
712                        .add_error("Usage: encode {\"data\": true} or encode $var".to_string());
713                    return Ok(());
714                }
715
716                match serde_json::from_str::<serde_json::Value>(&data) {
717                    Ok(json_value) => match encode(&json_value, &self.app_state.encode_options) {
718                        Ok(toon_str) => {
719                            self.app_state.repl.add_success(toon_str.clone());
720                            self.app_state.repl.last_result = Some(toon_str);
721                        }
722                        Err(e) => {
723                            self.app_state.repl.add_error(format!("Encode error: {e}"));
724                        }
725                    },
726                    Err(e) => {
727                        self.app_state.repl.add_error(format!("Invalid JSON: {e}"));
728                    }
729                }
730            }
731            "decode" | "d" => {
732                let mut data = cmd
733                    .inline_data
734                    .as_ref()
735                    .map(|s| s.to_string())
736                    .unwrap_or_else(String::new);
737
738                data = self.substitute_variables(&data);
739
740                if data.is_empty() {
741                    self.app_state
742                        .repl
743                        .add_error("Usage: decode name: Alice or decode $var".to_string());
744                    return Ok(());
745                }
746
747                match decode::<serde_json::Value>(&data, &self.app_state.decode_options) {
748                    Ok(json_value) => match serde_json::to_string_pretty(&json_value) {
749                        Ok(json_str) => {
750                            self.app_state.repl.add_success(json_str.clone());
751                            self.app_state.repl.last_result = Some(json_str);
752                        }
753                        Err(e) => {
754                            self.app_state.repl.add_error(format!("JSON error: {e}"));
755                        }
756                    },
757                    Err(e) => {
758                        self.app_state.repl.add_error(format!("Decode error: {e}"));
759                    }
760                }
761            }
762            "let" => {
763                let parts: Vec<&str> = input.splitn(2, '=').collect();
764                if parts.len() == 2 {
765                    let var_part = parts[0].trim().trim_start_matches("let").trim();
766                    let data_part = parts[1].trim();
767
768                    if !var_part.is_empty() && !data_part.is_empty() {
769                        let var_name = var_part.trim_start_matches('$');
770                        self.app_state
771                            .repl
772                            .variables
773                            .insert(var_name.to_string(), data_part.to_string());
774                        self.app_state
775                            .repl
776                            .add_info(format!("Stored in ${var_name}"));
777                        self.app_state.repl.last_result = Some(data_part.to_string());
778                    } else {
779                        self.app_state
780                            .repl
781                            .add_error("Usage: let $var = {\"data\": true}".to_string());
782                    }
783                } else {
784                    self.app_state
785                        .repl
786                        .add_error("Usage: let $var = {\"data\": true}".to_string());
787                }
788            }
789            "vars" => {
790                if self.app_state.repl.variables.is_empty() {
791                    self.app_state
792                        .repl
793                        .add_info("No variables defined".to_string());
794                } else {
795                    let vars: Vec<String> = self
796                        .app_state
797                        .repl
798                        .variables
799                        .keys()
800                        .map(|k| format!("${k}"))
801                        .collect();
802                    for var in vars {
803                        self.app_state.repl.add_info(var);
804                    }
805                }
806            }
807            "clear" => {
808                self.app_state.repl.output.clear();
809                self.app_state
810                    .repl
811                    .output
812                    .push(crate::tui::state::ReplLine {
813                        kind: crate::tui::state::ReplLineKind::Info,
814                        content: "Cleared".to_string(),
815                    });
816            }
817            "help" | "h" => {
818                self.app_state
819                    .repl
820                    .add_info("📖 REPL Commands:".to_string());
821                self.app_state.repl.add_info("".to_string());
822                self.app_state
823                    .repl
824                    .add_info("  encode {\"data\": true}  - Encode JSON to TOON".to_string());
825                self.app_state
826                    .repl
827                    .add_info("  decode name: Alice      - Decode TOON to JSON".to_string());
828                self.app_state
829                    .repl
830                    .add_info("  let $var = {...}        - Store data in variable".to_string());
831                self.app_state
832                    .repl
833                    .add_info("  vars                    - List all variables".to_string());
834                self.app_state
835                    .repl
836                    .add_info("  clear                   - Clear session".to_string());
837                self.app_state
838                    .repl
839                    .add_info("  help                    - Show this help".to_string());
840                self.app_state
841                    .repl
842                    .add_info("  exit                    - Close REPL".to_string());
843                self.app_state.repl.add_info("".to_string());
844                self.app_state
845                    .repl
846                    .add_info("Press ↑/↓ for history, Esc to close".to_string());
847            }
848            "exit" | "quit" | "q" => {
849                self.app_state.repl.add_info("Closing REPL...".to_string());
850                self.app_state.repl.deactivate();
851            }
852            _ => {
853                self.app_state
854                    .repl
855                    .add_error(format!("Unknown command: {}. Type 'help'", cmd.name));
856            }
857        }
858
859        Ok(())
860    }
861
862    /// Replace $var and $_ with their stored values.
863    fn substitute_variables(&self, text: &str) -> String {
864        let mut result = text.to_string();
865
866        // $_ is the last result
867        if let Some(last) = &self.app_state.repl.last_result {
868            result = result.replace("$_", last);
869        }
870
871        // Variables are stored without $, add it for matching
872        for (var_name, var_value) in &self.app_state.repl.variables {
873            let pattern = format!("${var_name}");
874            result = result.replace(&pattern, var_value);
875        }
876
877        result
878    }
879}
880
881impl<'a> Default for TuiApp<'a> {
882    fn default() -> Self {
883        Self::new()
884    }
885}