Skip to main content

netget/ui/
app.rs

1//! Simplified application state for rolling terminal
2//!
3//! This module provides the minimal state needed for the rolling terminal interface.
4//! Rendering lives in `src/tui/`; this keeps the shared command-line state.
5
6use std::fs::{File, OpenOptions};
7use std::io::{BufRead, BufReader, Write as _};
8use std::path::PathBuf;
9use tracing::{debug, warn};
10
11/// Log level for output verbosity
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
13pub enum LogLevel {
14    /// ERROR: Critical errors only
15    Error,
16    /// WARN: Warnings and errors
17    Warn,
18    /// INFO: One line per request/response (default)
19    #[default]
20    Info,
21    /// DEBUG: Detailed LLM responses, memory updates, actions
22    Debug,
23    /// TRACE: Full protocol and LLM content
24    Trace,
25}
26
27impl LogLevel {
28    pub fn parse(s: &str) -> Option<Self> {
29        match s.to_lowercase().as_str() {
30            "error" => Some(LogLevel::Error),
31            "warn" => Some(LogLevel::Warn),
32            "info" | "transactional" => Some(LogLevel::Info),
33            "debug" => Some(LogLevel::Debug),
34            "trace" | "verbose" => Some(LogLevel::Trace),
35            _ => None,
36        }
37    }
38
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            LogLevel::Error => "ERROR",
42            LogLevel::Warn => "WARN",
43            LogLevel::Info => "INFO",
44            LogLevel::Debug => "DEBUG",
45            LogLevel::Trace => "TRACE",
46        }
47    }
48
49    /// Cycle to the next log level (only cycles through main 3 levels)
50    pub fn cycle(&self) -> Self {
51        match self {
52            LogLevel::Error => LogLevel::Info,
53            LogLevel::Info => LogLevel::Trace,
54            LogLevel::Trace => LogLevel::Error,
55            // If currently on granular levels (set via CLI/slash), cycle to next main level
56            LogLevel::Warn => LogLevel::Info,
57            LogLevel::Debug => LogLevel::Trace,
58        }
59    }
60
61    /// Get the color for this log level (matches output rendering colors)
62    pub fn color(&self) -> crossterm::style::Color {
63        match self {
64            LogLevel::Error => crossterm::style::Color::Red,
65            LogLevel::Warn => crossterm::style::Color::Yellow,
66            LogLevel::Info => crossterm::style::Color::Green,
67            LogLevel::Debug => crossterm::style::Color::Cyan,
68            LogLevel::Trace => crossterm::style::Color::Blue,
69        }
70    }
71}
72
73/// Server information for display
74#[derive(Debug, Clone)]
75pub struct ServerDisplayInfo {
76    pub id: String,
77    pub protocol: String,
78    pub port: u16,
79    pub status: String,
80    pub connections: usize,
81}
82
83/// Client information for display
84#[derive(Debug, Clone)]
85pub struct ClientDisplayInfo {
86    pub id: String,
87    pub protocol: String,
88    pub remote_addr: String,
89    pub status: String,
90}
91
92/// Connection information for display
93#[derive(Debug, Clone)]
94pub struct ConnectionDisplayInfo {
95    pub id: u32,
96    pub server_id: String,
97    pub address: String,
98    pub state: String,
99}
100
101/// Scheduled task information for display
102#[derive(Debug, Clone)]
103pub struct TaskDisplayInfo {
104    pub id: String,
105    pub name: String,
106    pub scope: String,
107    pub status: String,
108    pub task_type: String,
109}
110
111/// Connection information for status bar
112#[derive(Default, Clone)]
113pub struct ConnectionInfo {
114    pub mode: String,
115    pub protocol: String,
116    pub model: String,
117    pub local_addr: Option<String>,
118    pub remote_addr: Option<String>,
119    pub state: String,
120}
121
122/// Packet statistics for status bar
123#[derive(Default, Clone)]
124pub struct PacketStats {
125    pub packets_received: u64,
126    pub packets_sent: u64,
127}
128
129/// Simplified application state for rolling terminal
130pub struct App {
131    /// Command history
132    pub command_history: Vec<String>,
133    /// Current position in history (None = not browsing history)
134    pub history_position: Option<usize>,
135    /// Temporary buffer when browsing history
136    pub history_temp_input: Option<String>,
137    /// Connection information
138    pub connection_info: ConnectionInfo,
139    /// Packet statistics
140    pub packet_stats: PacketStats,
141    /// Current log level
142    pub log_level: LogLevel,
143    /// Slash command suggestions
144    pub slash_suggestions: Vec<String>,
145    /// Server list for display
146    pub servers: Vec<ServerDisplayInfo>,
147    /// Client list for display
148    pub clients: Vec<ClientDisplayInfo>,
149    /// Connection list for display
150    pub connections: Vec<ConnectionDisplayInfo>,
151    /// Scheduled tasks for display
152    pub tasks: Vec<TaskDisplayInfo>,
153    /// Whether to expand all connections (E key toggle)
154    pub expand_all_connections: bool,
155    /// Next global connection ID to assign
156    pub next_global_connection_id: u32,
157    /// Mapping from network ConnectionId to global UI ID
158    pub connection_id_map: std::collections::HashMap<String, u32>,
159    /// System capabilities (for privilege warnings in status bar)
160    pub system_capabilities: crate::privilege::SystemCapabilities,
161    /// Active and recently-completed conversations
162    pub conversations: Vec<crate::state::app_state::ConversationInfo>,
163    /// Whether to show the usage stats section
164    pub show_usage_stats: bool,
165    /// Current system stats (CPU, memory, GPU)
166    pub system_stats: crate::system_stats::SystemStats,
167    /// Active interactive create/update form (`/create`, `/edit`). When `Some`,
168    /// keystrokes drive the form (Enter submits the current field, Esc cancels)
169    /// instead of the normal command line.
170    pub active_form: Option<crate::cli::management::InteractiveForm>,
171}
172
173impl Default for App {
174    fn default() -> Self {
175        Self {
176            command_history: Vec::new(),
177            history_position: None,
178            history_temp_input: None,
179            connection_info: ConnectionInfo::default(),
180            packet_stats: PacketStats::default(),
181            log_level: LogLevel::default(), // Defaults to INFO
182            slash_suggestions: Vec::new(),
183            servers: Vec::new(),
184            clients: Vec::new(),
185            connections: Vec::new(),
186            tasks: Vec::new(),
187            expand_all_connections: false,
188            next_global_connection_id: 1,
189            connection_id_map: std::collections::HashMap::new(),
190            system_capabilities: crate::privilege::SystemCapabilities::detect(),
191            conversations: Vec::new(),
192            show_usage_stats: false,
193            system_stats: crate::system_stats::SystemStats::default(),
194            active_form: None,
195        }
196    }
197}
198
199impl App {
200    /// Create a new App with system capabilities (loads history from ~/.netget_history)
201    pub fn new(system_capabilities: crate::privilege::SystemCapabilities) -> Self {
202        let mut app = Self {
203            system_capabilities,
204            ..Default::default()
205        };
206        app.command_history = Self::load_history();
207        app
208    }
209
210    /// Get the path to the history file
211    fn history_file_path() -> Option<PathBuf> {
212        dirs::home_dir().map(|mut path| {
213            path.push(".netget_history");
214            path
215        })
216    }
217
218    /// Escape newlines and other special characters for storage
219    fn escape_for_storage(s: &str) -> String {
220        s.replace('\\', "\\\\")
221            .replace('\n', "\\n")
222            .replace('\r', "\\r")
223            .replace('\t', "\\t")
224    }
225
226    /// Unescape newlines and other special characters from storage
227    fn unescape_from_storage(s: &str) -> String {
228        let mut result = String::with_capacity(s.len());
229        let mut chars = s.chars();
230
231        while let Some(ch) = chars.next() {
232            if ch == '\\' {
233                match chars.next() {
234                    Some('n') => result.push('\n'),
235                    Some('r') => result.push('\r'),
236                    Some('t') => result.push('\t'),
237                    Some('\\') => result.push('\\'),
238                    Some(c) => {
239                        result.push('\\');
240                        result.push(c);
241                    }
242                    None => result.push('\\'),
243                }
244            } else {
245                result.push(ch);
246            }
247        }
248
249        result
250    }
251
252    /// Load command history from file
253    fn load_history() -> Vec<String> {
254        let Some(path) = Self::history_file_path() else {
255            warn!("Could not determine home directory for history file");
256            return Vec::new();
257        };
258
259        if !path.exists() {
260            debug!("History file does not exist yet: {:?}", path);
261            return Vec::new();
262        }
263
264        match File::open(&path) {
265            Ok(file) => {
266                let reader = BufReader::new(file);
267                let history: Vec<String> = reader
268                    .lines()
269                    .map_while(Result::ok)
270                    .filter(|line| !line.trim().is_empty())
271                    .map(|line| Self::unescape_from_storage(&line))
272                    .collect();
273                debug!("Loaded {} commands from history", history.len());
274                history
275            }
276            Err(e) => {
277                warn!("Failed to open history file: {}", e);
278                Vec::new()
279            }
280        }
281    }
282
283    /// Save command history to file
284    pub fn save_history(&self) -> std::io::Result<()> {
285        let Some(path) = Self::history_file_path() else {
286            return Ok(()); // Silently skip if can't determine home dir
287        };
288
289        let mut file = OpenOptions::new()
290            .create(true)
291            .write(true)
292            .truncate(true)
293            .open(&path)?;
294
295        for command in &self.command_history {
296            writeln!(file, "{}", Self::escape_for_storage(command))?;
297        }
298
299        debug!("Saved {} commands to history", self.command_history.len());
300        Ok(())
301    }
302
303    /// Add command to history (deduplicates)
304    pub fn add_to_history(&mut self, command: String) {
305        if !command.trim().is_empty()
306            && (self.command_history.is_empty() || self.command_history.last() != Some(&command))
307        {
308            self.command_history.push(command);
309        }
310        // Reset history navigation state so pressing up shows the last command
311        self.history_position = None;
312        self.history_temp_input = None;
313    }
314
315    /// Get or allocate a global connection ID for a network connection
316    pub fn get_or_allocate_connection_id(&mut self, network_conn_id: String) -> u32 {
317        if let Some(&id) = self.connection_id_map.get(&network_conn_id) {
318            id
319        } else {
320            let id = self.next_global_connection_id;
321            self.next_global_connection_id += 1;
322            self.connection_id_map.insert(network_conn_id, id);
323            id
324        }
325    }
326
327    /// Remove a connection from the ID map (when connection closes)
328    pub fn remove_connection_id(&mut self, network_conn_id: &str) {
329        self.connection_id_map.remove(network_conn_id);
330    }
331
332    /// Update slash command suggestions based on input
333    pub fn update_slash_suggestions(&mut self, input: &str) {
334        // Only show suggestions if input starts with "/"
335        if !input.starts_with('/') {
336            self.slash_suggestions.clear();
337            return;
338        }
339
340        // Define all available slash commands
341        let all_commands = vec![
342            "/exit - Exit the application",
343            "/model [<name>] - List/select a model",
344            "/log [<level>] - Show/set log level (error, warn, info, debug, trace)",
345            "/script [<env>] - Show/set scripting environment (llm, python, javascript, go)",
346            "/web [on/off/ask] - Show/set web search mode",
347            "/test_ask - Test web search approval prompt",
348            "/backend [ollama|openai <url>] - Show/switch LLM backend",
349            "/docs [<protocol>] - Show protocol documentation",
350            "/stability - List protocols grouped by development state",
351            "/manage - List running servers/clients and show create/update shapes",
352            "/create <protocol> [--client] - Interactive prefilled create form",
353            "/edit <id> - Interactive form prefilled with a running instance's config",
354            "/update <id> <instruction> - Update a running server/client in place",
355        ];
356
357        // Filter commands based on current input
358        let input_lower = input.to_lowercase();
359        self.slash_suggestions = all_commands
360            .into_iter()
361            .filter(|cmd| cmd.to_lowercase().starts_with(&input_lower))
362            .map(|s| s.to_string())
363            .collect();
364    }
365
366    /// Set log level
367    pub fn set_log_level(&mut self, level: LogLevel) {
368        self.log_level = level;
369    }
370
371    /// Toggle expand all connections
372    pub fn toggle_expand_all(&mut self) {
373        self.expand_all_connections = !self.expand_all_connections;
374    }
375
376    /// Toggle usage stats visibility
377    pub fn toggle_usage_stats(&mut self) {
378        self.show_usage_stats = !self.show_usage_stats;
379    }
380
381    /// Legacy compatibility: add_llm_message (no-op in rolling terminal, output goes to stdout)
382    pub fn add_llm_message(&mut self, _message: String) {
383        // In rolling terminal mode, messages are printed directly to stdout
384        // This method exists for compatibility with event handler
385    }
386}