1
//! Console tab state: an interactive nomiscript REPL.
2
//!
3
//! This module holds the *pure* console state — the input editor, a
4
//! multi-line `pending` form buffer (filled until a balanced form is
5
//! read), a bounded scrollback transcript, and a command history. The
6
//! async eval bridge (`ConsoleEval`) lives in a sibling module; this
7
//! file is intentionally I/O-free so it can be unit-tested without a
8
//! runtime or a database.
9

            
10
use crate::widgets::{EditMode, Editor};
11
use scripting::nomiscript::Reader;
12

            
13
/// Maximum number of scrollback lines retained. A long-lived SSH
14
/// session must not grow memory without bound, so older lines are
15
/// dropped once this cap is reached.
16
pub const MAX_SCROLLBACK_LINES: usize = 1000;
17

            
18
/// Maximum number of submitted forms retained for history navigation.
19
/// As with scrollback, a long-lived session must not retain unbounded
20
/// submitted-form text, so the oldest entries are dropped past this cap.
21
pub const MAX_HISTORY_ENTRIES: usize = 1000;
22

            
23
/// Pure state for the Console tab.
24
#[derive(Debug)]
25
pub struct ConsoleState {
26
    /// The single-line input editor for the current line.
27
    pub input: Editor,
28
    /// Lines of an in-progress multi-line form, joined with `\n` once a
29
    /// balanced form is assembled. Empty when no form is pending.
30
    pub pending: String,
31
    /// The rendered transcript (prompts, inputs, and eval results).
32
    pub scrollback: Vec<String>,
33
    /// Previously submitted complete forms, oldest first.
34
    pub history: Vec<String>,
35
    /// Cursor into `history` for up/down navigation; `None` means the
36
    /// cursor sits below the newest entry (i.e. on a fresh line).
37
    pub history_cursor: Option<usize>,
38
}
39

            
40
impl Default for ConsoleState {
41
    fn default() -> Self {
42
        Self::new()
43
    }
44
}
45

            
46
impl ConsoleState {
47
    /// The console input is always an Emacs-style line editor, regardless
48
    /// of the app's edit mode. The REPL has no vim-normal routing or
49
    /// two-stage Esc, so building it in Vim mode (reachable at runtime via
50
    /// `Ctrl-V`) would strand it in an unhandled normal mode where motion
51
    /// keys insert literally; pinning Emacs keeps the input always usable.
52
    #[must_use]
53
69
    pub fn new() -> Self {
54
69
        Self {
55
69
            input: Editor::new(EditMode::Emacs),
56
69
            pending: String::new(),
57
69
            scrollback: Vec::new(),
58
69
            history: Vec::new(),
59
69
            history_cursor: None,
60
69
        }
61
69
    }
62

            
63
    /// Consume the current input line. If, together with any pending
64
    /// lines, it forms a balanced nomiscript form, return that complete
65
    /// form string, clear the buffers, and record it in `history`. An
66
    /// unbalanced (incomplete) form is appended to `pending` and `None`
67
    /// is returned, so the caller keeps collecting lines. A blank /
68
    /// whitespace-only candidate yields `None` without echoing or
69
    /// recording history (a bare Enter must not submit an empty form).
70
1027
    pub fn take_complete_form(&mut self) -> Option<String> {
71
1027
        let line = self.input.buffer().to_string();
72
1027
        self.input = Editor::new(self.input.mode());
73

            
74
1027
        let candidate = match self.pending.is_empty() {
75
1026
            true => line,
76
1
            false => format!("{}\n{}", self.pending, line),
77
        };
78

            
79
1027
        if candidate.trim().is_empty() {
80
2
            return None;
81
1025
        }
82

            
83
1025
        match Reader::is_incomplete(&candidate) {
84
            true => {
85
5
                self.pending = candidate;
86
5
                None
87
            }
88
            false => {
89
1020
                self.pending.clear();
90
1020
                self.push_history(candidate.clone());
91
1020
                self.history_cursor = None;
92
1020
                Some(candidate)
93
            }
94
        }
95
1027
    }
96

            
97
    /// Record a submitted form, dropping the oldest entries once the
98
    /// retained count would exceed [`MAX_HISTORY_ENTRIES`].
99
1020
    fn push_history(&mut self, form: String) {
100
1020
        self.history.push(form);
101
1020
        let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES);
102
1020
        if overflow > 0 {
103
5
            self.history.drain(0..overflow);
104
1015
        }
105
1020
    }
106

            
107
    /// Move the history cursor toward older entries, loading the entry
108
    /// at the new cursor into the input editor. A no-op on empty history;
109
    /// once the cursor reaches the oldest entry it stays there.
110
10
    pub fn history_prev(&mut self) {
111
10
        if self.history.is_empty() {
112
1
            return;
113
9
        }
114
9
        let next = match self.history_cursor {
115
4
            None => self.history.len() - 1,
116
1
            Some(0) => 0,
117
4
            Some(i) => i - 1,
118
        };
119
9
        self.history_cursor = Some(next);
120
9
        self.load_history_entry();
121
10
    }
122

            
123
    /// Move the history cursor toward newer entries, loading the entry
124
    /// into the input editor. Stepping past the newest entry clears the
125
    /// cursor and the input line (a fresh prompt).
126
5
    pub fn history_next(&mut self) {
127
5
        let Some(cursor) = self.history_cursor else {
128
2
            return;
129
        };
130
3
        match cursor + 1 < self.history.len() {
131
2
            true => {
132
2
                self.history_cursor = Some(cursor + 1);
133
2
                self.load_history_entry();
134
2
            }
135
1
            false => {
136
1
                self.history_cursor = None;
137
1
                self.input = Editor::new(self.input.mode());
138
1
            }
139
        }
140
5
    }
141

            
142
    /// Replace the input editor with the history entry at the current
143
    /// cursor. Caller guarantees the cursor points at a valid index.
144
11
    fn load_history_entry(&mut self) {
145
11
        if let Some(entry) = self.history_cursor.and_then(|i| self.history.get(i)) {
146
11
            self.input = Editor::with_buffer(self.input.mode(), entry.clone());
147
11
        }
148
11
    }
149

            
150
    /// Append a line to the scrollback, dropping the oldest lines once
151
    /// the retained count would exceed [`MAX_SCROLLBACK_LINES`].
152
1120
    pub fn push_scrollback(&mut self, line: impl Into<String>) {
153
1120
        self.scrollback.push(line.into());
154
1120
        let overflow = self.scrollback.len().saturating_sub(MAX_SCROLLBACK_LINES);
155
1120
        if overflow > 0 {
156
5
            self.scrollback.drain(0..overflow);
157
1115
        }
158
1120
    }
159
}
160

            
161
/// Render an RPC envelope into scrollback lines. Today this is a
162
/// verbatim line split; it is the single seam a future value
163
/// pretty-printer replaces without touching call sites.
164
#[must_use]
165
5
pub fn format_result(envelope: &str) -> Vec<String> {
166
5
    envelope.lines().map(str::to_string).collect()
167
5
}
168

            
169
#[cfg(test)]
170
mod tests;