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 cli_core::render::{RenderError, WireValue, parse_wire, reparse_list, value_to_rows};
11
use scripting::nomiscript::{Reader, Value, format_value, list_to_vec};
12

            
13
use crate::pane::{PaneId, PaneSet};
14
use crate::widgets::{EditMode, Editor};
15

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

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

            
26
/// Pure state for the Console tab.
27
#[derive(Debug)]
28
pub struct ConsoleState {
29
    /// The single-line input editor for the current line.
30
    pub input: Editor,
31
    /// Lines of an in-progress multi-line form, joined with `\n` once a
32
    /// balanced form is assembled. Empty when no form is pending.
33
    pub pending: String,
34
    /// The rendered transcript (prompts, inputs, and eval results).
35
    pub scrollback: Vec<String>,
36
    /// Previously submitted complete forms, oldest first.
37
    pub history: Vec<String>,
38
    /// Cursor into `history` for up/down navigation; `None` means the
39
    /// cursor sits below the newest entry (i.e. on a fresh line).
40
    pub history_cursor: Option<usize>,
41
    /// Which sub-pane (Prompt or Scrollback) currently has focus.
42
    pub panes: PaneSet,
43
    /// Lines scrolled up from the bottom (0 = newest/tail).
44
    pub scroll: usize,
45
}
46

            
47
impl Default for ConsoleState {
48
    fn default() -> Self {
49
        Self::new()
50
    }
51
}
52

            
53
impl ConsoleState {
54
    const PANES: &'static [PaneId] = &[PaneId::Prompt, PaneId::Scrollback];
55

            
56
    /// The console input is always an Emacs-style line editor, regardless
57
    /// of the app's edit mode. The REPL has no vim-normal routing or
58
    /// two-stage Esc, so building it in Vim mode (reachable at runtime via
59
    /// `Ctrl-V`) would strand it in an unhandled normal mode where motion
60
    /// keys insert literally; pinning Emacs keeps the input always usable.
61
    #[must_use]
62
184
    pub fn new() -> Self {
63
184
        Self {
64
184
            input: Editor::new(EditMode::Emacs),
65
184
            pending: String::new(),
66
184
            scrollback: Vec::new(),
67
184
            history: Vec::new(),
68
184
            history_cursor: None,
69
184
            panes: PaneSet::new(Self::PANES, PaneId::Prompt),
70
184
            scroll: 0,
71
184
        }
72
184
    }
73

            
74
    /// Consume the current input line. If, together with any pending
75
    /// lines, it forms a balanced nomiscript form, return that complete
76
    /// form string, clear the buffers, and record it in `history`. An
77
    /// unbalanced (incomplete) form is appended to `pending` and `None`
78
    /// is returned, so the caller keeps collecting lines. A blank /
79
    /// whitespace-only candidate yields `None` without echoing or
80
    /// recording history (a bare Enter must not submit an empty form).
81
1027
    pub fn take_complete_form(&mut self) -> Option<String> {
82
1027
        let line = self.input.buffer().to_string();
83
1027
        self.input = Editor::new(self.input.mode());
84

            
85
1027
        let candidate = match self.pending.is_empty() {
86
1026
            true => line,
87
1
            false => format!("{}\n{}", self.pending, line),
88
        };
89

            
90
1027
        if candidate.trim().is_empty() {
91
2
            return None;
92
1025
        }
93

            
94
1025
        match Reader::is_incomplete(&candidate) {
95
            true => {
96
5
                self.pending = candidate;
97
5
                None
98
            }
99
            false => {
100
1020
                self.pending.clear();
101
1020
                self.push_history(candidate.clone());
102
1020
                self.history_cursor = None;
103
1020
                Some(candidate)
104
            }
105
        }
106
1027
    }
107

            
108
    /// Record a submitted form, dropping the oldest entries once the
109
    /// retained count would exceed [`MAX_HISTORY_ENTRIES`].
110
1020
    fn push_history(&mut self, form: String) {
111
1020
        self.history.push(form);
112
1020
        let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES);
113
1020
        if overflow > 0 {
114
5
            self.history.drain(0..overflow);
115
1015
        }
116
1020
    }
117

            
118
    /// Move the history cursor toward older entries, loading the entry
119
    /// at the new cursor into the input editor. A no-op on empty history;
120
    /// once the cursor reaches the oldest entry it stays there.
121
10
    pub fn history_prev(&mut self) {
122
10
        if self.history.is_empty() {
123
1
            return;
124
9
        }
125
9
        let next = match self.history_cursor {
126
4
            None => self.history.len() - 1,
127
1
            Some(0) => 0,
128
4
            Some(i) => i - 1,
129
        };
130
9
        self.history_cursor = Some(next);
131
9
        self.load_history_entry();
132
10
    }
133

            
134
    /// Move the history cursor toward newer entries, loading the entry
135
    /// into the input editor. Stepping past the newest entry clears the
136
    /// cursor and the input line (a fresh prompt).
137
5
    pub fn history_next(&mut self) {
138
5
        let Some(cursor) = self.history_cursor else {
139
2
            return;
140
        };
141
3
        match cursor + 1 < self.history.len() {
142
2
            true => {
143
2
                self.history_cursor = Some(cursor + 1);
144
2
                self.load_history_entry();
145
2
            }
146
1
            false => {
147
1
                self.history_cursor = None;
148
1
                self.input = Editor::new(self.input.mode());
149
1
            }
150
        }
151
5
    }
152

            
153
    /// Replace the input editor with the history entry at the current
154
    /// cursor. Caller guarantees the cursor points at a valid index.
155
11
    fn load_history_entry(&mut self) {
156
11
        if let Some(entry) = self.history_cursor.and_then(|i| self.history.get(i)) {
157
11
            self.input = Editor::with_buffer(self.input.mode(), entry.clone());
158
11
        }
159
11
    }
160

            
161
    /// Append a line to the scrollback, dropping the oldest lines once
162
    /// the retained count would exceed [`MAX_SCROLLBACK_LINES`].
163
1291
    pub fn push_scrollback(&mut self, line: impl Into<String>) {
164
1291
        self.scrollback.push(line.into());
165
1291
        let overflow = self.scrollback.len().saturating_sub(MAX_SCROLLBACK_LINES);
166
1291
        if overflow > 0 {
167
5
            self.scrollback.drain(0..overflow);
168
1286
        }
169
1291
    }
170

            
171
    /// Scroll up by `n` lines (toward older content). Clamped to the
172
    /// scrollback length so the offset can never exceed the buffer size.
173
12
    pub fn scroll_up(&mut self, n: usize) {
174
12
        self.scroll = (self.scroll + n).min(self.scrollback.len());
175
12
    }
176

            
177
    /// Scroll down by `n` lines (toward newer content). Saturates at 0.
178
5
    pub fn scroll_down(&mut self, n: usize) {
179
5
        self.scroll = self.scroll.saturating_sub(n);
180
5
    }
181

            
182
    /// Reset the scroll offset to the bottom (newest line visible).
183
13
    pub fn reset_scroll(&mut self) {
184
13
        self.scroll = 0;
185
13
    }
186

            
187
    /// The slice of scrollback lines that fit in a viewport of `height` rows,
188
    /// respecting the current scroll offset. The effective offset is clamped
189
    /// to the viewport (`len - height`) so the visible window is never emptied
190
    /// past the oldest line, even when the stored offset exceeds that bound.
191
    #[must_use]
192
16
    pub fn visible_scrollback(&self, height: usize) -> &[String] {
193
16
        let len = self.scrollback.len();
194
16
        let max_scroll = len.saturating_sub(height);
195
16
        let scroll = self.scroll.min(max_scroll);
196
16
        let end = len - scroll;
197
16
        let start = end.saturating_sub(height);
198
16
        &self.scrollback[start..end]
199
16
    }
200
}
201

            
202
/// Pad each column in `rows` to the maximum width in that column and
203
/// join cells with two spaces. Empty `rows` yields an empty `Vec`.
204
///
205
/// Width is measured in Unicode scalars (`chars().count()`); wide (CJK) or
206
/// zero-width characters in account/commodity names can therefore misalign
207
/// columns slightly. Acceptable for the Console scrollback — the dedicated
208
/// data tabs render through ratatui's own width-aware `Table`.
209
#[must_use]
210
8
pub fn align_rows(rows: &[Vec<String>]) -> Vec<String> {
211
8
    if rows.is_empty() {
212
1
        return vec![];
213
7
    }
214
12
    let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
215
7
    let widths: Vec<usize> = (0..col_count)
216
12
        .map(|c| {
217
12
            rows.iter()
218
21
                .map(|r| r.get(c).map_or(0, |s| s.chars().count()))
219
12
                .max()
220
12
                .unwrap_or(0)
221
12
        })
222
7
        .collect();
223
7
    rows.iter()
224
12
        .map(|row| {
225
12
            row.iter()
226
12
                .enumerate()
227
19
                .map(|(c, cell)| {
228
19
                    let w = widths.get(c).copied().unwrap_or(0);
229
19
                    format!("{cell:<w$}", w = w)
230
19
                })
231
12
                .collect::<Vec<_>>()
232
12
                .join("  ")
233
12
                .trim_end()
234
12
                .to_string()
235
12
        })
236
7
        .collect()
237
8
}
238

            
239
/// Tabulate `s` when it is a non-empty proper list — the shape every list
240
/// command returns, including `pair:string` results like `get-balances` whose
241
/// elements are plist strings. The Console is typeless (unlike the CLI's
242
/// per-command `RenderMode` and the dedicated data tabs), so this is
243
/// best-effort: an empty list or a non-list renders verbatim, and a rare scalar
244
/// string that happens to read as a list is shown as rows. `value_to_rows`
245
/// owns the per-element formatting, exactly as the CLI list renderers do.
246
6
fn render_list_value(s: &str) -> Option<Vec<String>> {
247
6
    let trimmed = s.trim_start();
248
6
    if !trimmed.starts_with('(') {
249
2
        return None;
250
4
    }
251
4
    let value = reparse_list(trimmed).ok()?;
252
4
    if list_to_vec(&value).is_none_or(|els| els.is_empty()) {
253
1
        return None;
254
3
    }
255
3
    let rows = value_to_rows(&value);
256
3
    if rows.is_empty() {
257
        return None;
258
3
    }
259
3
    Some(align_rows(&rows))
260
6
}
261

            
262
/// Render an RPC response envelope into scrollback lines.
263
///
264
/// Structured responses: list payloads → aligned table, scalars → one
265
/// line, server errors → `[error] code: message`. Anything that does
266
/// not parse as a response envelope (request frames, plain notices)
267
/// falls back to verbatim line split.
268
#[must_use]
269
20
pub fn format_result(envelope: &str) -> Vec<String> {
270
20
    match parse_wire(envelope) {
271
6
        Ok(WireValue::Value(Value::String(ref s))) => {
272
6
            render_list_value(s).unwrap_or_else(|| vec![s.clone()])
273
        }
274
4
        Ok(WireValue::Value(ref v)) => {
275
4
            vec![format_value(v)]
276
        }
277
2
        Err(RenderError::Server { code, message }) => {
278
2
            vec![format!("[error] {code}: {message}")]
279
        }
280
8
        Err(_) => envelope.lines().map(str::to_string).collect(),
281
    }
282
20
}
283

            
284
#[cfg(test)]
285
mod tests;