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 cli_core::render::{RenderError, WireValue, parse_wire, reparse_list, value_to_rows};
11use scripting::nomiscript::{Reader, Value, format_value, list_to_vec};
12
13use crate::pane::{PaneId, PaneSet};
14use 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.
19pub 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.
24pub const MAX_HISTORY_ENTRIES: usize = 1000;
25
26/// Pure state for the Console tab.
27#[derive(Debug)]
28pub 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
47impl Default for ConsoleState {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl 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 pub fn new() -> Self {
63 Self {
64 input: Editor::new(EditMode::Emacs),
65 pending: String::new(),
66 scrollback: Vec::new(),
67 history: Vec::new(),
68 history_cursor: None,
69 panes: PaneSet::new(Self::PANES, PaneId::Prompt),
70 scroll: 0,
71 }
72 }
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 pub fn take_complete_form(&mut self) -> Option<String> {
82 let line = self.input.buffer().to_string();
83 self.input = Editor::new(self.input.mode());
84
85 let candidate = match self.pending.is_empty() {
86 true => line,
87 false => format!("{}\n{}", self.pending, line),
88 };
89
90 if candidate.trim().is_empty() {
91 return None;
92 }
93
94 match Reader::is_incomplete(&candidate) {
95 true => {
96 self.pending = candidate;
97 None
98 }
99 false => {
100 self.pending.clear();
101 self.push_history(candidate.clone());
102 self.history_cursor = None;
103 Some(candidate)
104 }
105 }
106 }
107
108 /// Record a submitted form, dropping the oldest entries once the
109 /// retained count would exceed [`MAX_HISTORY_ENTRIES`].
110 fn push_history(&mut self, form: String) {
111 self.history.push(form);
112 let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES);
113 if overflow > 0 {
114 self.history.drain(0..overflow);
115 }
116 }
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 pub fn history_prev(&mut self) {
122 if self.history.is_empty() {
123 return;
124 }
125 let next = match self.history_cursor {
126 None => self.history.len() - 1,
127 Some(0) => 0,
128 Some(i) => i - 1,
129 };
130 self.history_cursor = Some(next);
131 self.load_history_entry();
132 }
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 pub fn history_next(&mut self) {
138 let Some(cursor) = self.history_cursor else {
139 return;
140 };
141 match cursor + 1 < self.history.len() {
142 true => {
143 self.history_cursor = Some(cursor + 1);
144 self.load_history_entry();
145 }
146 false => {
147 self.history_cursor = None;
148 self.input = Editor::new(self.input.mode());
149 }
150 }
151 }
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 fn load_history_entry(&mut self) {
156 if let Some(entry) = self.history_cursor.and_then(|i| self.history.get(i)) {
157 self.input = Editor::with_buffer(self.input.mode(), entry.clone());
158 }
159 }
160
161 /// Append a line to the scrollback, dropping the oldest lines once
162 /// the retained count would exceed [`MAX_SCROLLBACK_LINES`].
163 pub fn push_scrollback(&mut self, line: impl Into<String>) {
164 self.scrollback.push(line.into());
165 let overflow = self.scrollback.len().saturating_sub(MAX_SCROLLBACK_LINES);
166 if overflow > 0 {
167 self.scrollback.drain(0..overflow);
168 }
169 }
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 pub fn scroll_up(&mut self, n: usize) {
174 self.scroll = (self.scroll + n).min(self.scrollback.len());
175 }
176
177 /// Scroll down by `n` lines (toward newer content). Saturates at 0.
178 pub fn scroll_down(&mut self, n: usize) {
179 self.scroll = self.scroll.saturating_sub(n);
180 }
181
182 /// Reset the scroll offset to the bottom (newest line visible).
183 pub fn reset_scroll(&mut self) {
184 self.scroll = 0;
185 }
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 pub fn visible_scrollback(&self, height: usize) -> &[String] {
193 let len = self.scrollback.len();
194 let max_scroll = len.saturating_sub(height);
195 let scroll = self.scroll.min(max_scroll);
196 let end = len - scroll;
197 let start = end.saturating_sub(height);
198 &self.scrollback[start..end]
199 }
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]
210pub fn align_rows(rows: &[Vec<String>]) -> Vec<String> {
211 if rows.is_empty() {
212 return vec![];
213 }
214 let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
215 let widths: Vec<usize> = (0..col_count)
216 .map(|c| {
217 rows.iter()
218 .map(|r| r.get(c).map_or(0, |s| s.chars().count()))
219 .max()
220 .unwrap_or(0)
221 })
222 .collect();
223 rows.iter()
224 .map(|row| {
225 row.iter()
226 .enumerate()
227 .map(|(c, cell)| {
228 let w = widths.get(c).copied().unwrap_or(0);
229 format!("{cell:<w$}", w = w)
230 })
231 .collect::<Vec<_>>()
232 .join(" ")
233 .trim_end()
234 .to_string()
235 })
236 .collect()
237}
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.
246fn render_list_value(s: &str) -> Option<Vec<String>> {
247 let trimmed = s.trim_start();
248 if !trimmed.starts_with('(') {
249 return None;
250 }
251 let value = reparse_list(trimmed).ok()?;
252 if list_to_vec(&value).is_none_or(|els| els.is_empty()) {
253 return None;
254 }
255 let rows = value_to_rows(&value);
256 if rows.is_empty() {
257 return None;
258 }
259 Some(align_rows(&rows))
260}
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]
269pub fn format_result(envelope: &str) -> Vec<String> {
270 match parse_wire(envelope) {
271 Ok(WireValue::Value(Value::String(ref s))) => {
272 render_list_value(s).unwrap_or_else(|| vec![s.clone()])
273 }
274 Ok(WireValue::Value(ref v)) => {
275 vec![format_value(v)]
276 }
277 Err(RenderError::Server { code, message }) => {
278 vec![format!("[error] {code}: {message}")]
279 }
280 Err(_) => envelope.lines().map(str::to_string).collect(),
281 }
282}
283
284#[cfg(test)]
285mod tests;