tui/tabs/nms.rs
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
10use crate::widgets::{EditMode, Editor};
11use 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.
16pub 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.
21pub const MAX_HISTORY_ENTRIES: usize = 1000;
22
23/// Pure state for the Console tab.
24#[derive(Debug)]
25pub 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
40impl Default for ConsoleState {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl 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 pub fn new() -> Self {
54 Self {
55 input: Editor::new(EditMode::Emacs),
56 pending: String::new(),
57 scrollback: Vec::new(),
58 history: Vec::new(),
59 history_cursor: None,
60 }
61 }
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 pub fn take_complete_form(&mut self) -> Option<String> {
71 let line = self.input.buffer().to_string();
72 self.input = Editor::new(self.input.mode());
73
74 let candidate = match self.pending.is_empty() {
75 true => line,
76 false => format!("{}\n{}", self.pending, line),
77 };
78
79 if candidate.trim().is_empty() {
80 return None;
81 }
82
83 match Reader::is_incomplete(&candidate) {
84 true => {
85 self.pending = candidate;
86 None
87 }
88 false => {
89 self.pending.clear();
90 self.push_history(candidate.clone());
91 self.history_cursor = None;
92 Some(candidate)
93 }
94 }
95 }
96
97 /// Record a submitted form, dropping the oldest entries once the
98 /// retained count would exceed [`MAX_HISTORY_ENTRIES`].
99 fn push_history(&mut self, form: String) {
100 self.history.push(form);
101 let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES);
102 if overflow > 0 {
103 self.history.drain(0..overflow);
104 }
105 }
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 pub fn history_prev(&mut self) {
111 if self.history.is_empty() {
112 return;
113 }
114 let next = match self.history_cursor {
115 None => self.history.len() - 1,
116 Some(0) => 0,
117 Some(i) => i - 1,
118 };
119 self.history_cursor = Some(next);
120 self.load_history_entry();
121 }
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 pub fn history_next(&mut self) {
127 let Some(cursor) = self.history_cursor else {
128 return;
129 };
130 match cursor + 1 < self.history.len() {
131 true => {
132 self.history_cursor = Some(cursor + 1);
133 self.load_history_entry();
134 }
135 false => {
136 self.history_cursor = None;
137 self.input = Editor::new(self.input.mode());
138 }
139 }
140 }
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 fn load_history_entry(&mut self) {
145 if let Some(entry) = self.history_cursor.and_then(|i| self.history.get(i)) {
146 self.input = Editor::with_buffer(self.input.mode(), entry.clone());
147 }
148 }
149
150 /// Append a line to the scrollback, dropping the oldest lines once
151 /// the retained count would exceed [`MAX_SCROLLBACK_LINES`].
152 pub fn push_scrollback(&mut self, line: impl Into<String>) {
153 self.scrollback.push(line.into());
154 let overflow = self.scrollback.len().saturating_sub(MAX_SCROLLBACK_LINES);
155 if overflow > 0 {
156 self.scrollback.drain(0..overflow);
157 }
158 }
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]
165pub fn format_result(envelope: &str) -> Vec<String> {
166 envelope.lines().map(str::to_string).collect()
167}
168
169#[cfg(test)]
170mod tests;