Lines
97.22 %
Functions
48 %
Branches
100 %
//! Console tab state: an interactive nomiscript REPL.
//!
//! This module holds the *pure* console state — the input editor, a
//! multi-line `pending` form buffer (filled until a balanced form is
//! read), a bounded scrollback transcript, and a command history. The
//! async eval bridge (`ConsoleEval`) lives in a sibling module; this
//! file is intentionally I/O-free so it can be unit-tested without a
//! runtime or a database.
use cli_core::render::{RenderError, WireValue, parse_wire, reparse_list, value_to_rows};
use scripting::nomiscript::{Reader, Value, format_value, list_to_vec};
use crate::pane::{PaneId, PaneSet};
use crate::widgets::{EditMode, Editor};
/// Maximum number of scrollback lines retained. A long-lived SSH
/// session must not grow memory without bound, so older lines are
/// dropped once this cap is reached.
pub const MAX_SCROLLBACK_LINES: usize = 1000;
/// Maximum number of submitted forms retained for history navigation.
/// As with scrollback, a long-lived session must not retain unbounded
/// submitted-form text, so the oldest entries are dropped past this cap.
pub const MAX_HISTORY_ENTRIES: usize = 1000;
/// Pure state for the Console tab.
#[derive(Debug)]
pub struct ConsoleState {
/// The single-line input editor for the current line.
pub input: Editor,
/// Lines of an in-progress multi-line form, joined with `\n` once a
/// balanced form is assembled. Empty when no form is pending.
pub pending: String,
/// The rendered transcript (prompts, inputs, and eval results).
pub scrollback: Vec<String>,
/// Previously submitted complete forms, oldest first.
pub history: Vec<String>,
/// Cursor into `history` for up/down navigation; `None` means the
/// cursor sits below the newest entry (i.e. on a fresh line).
pub history_cursor: Option<usize>,
/// Which sub-pane (Prompt or Scrollback) currently has focus.
pub panes: PaneSet,
/// Lines scrolled up from the bottom (0 = newest/tail).
pub scroll: usize,
}
impl Default for ConsoleState {
fn default() -> Self {
Self::new()
impl ConsoleState {
const PANES: &'static [PaneId] = &[PaneId::Prompt, PaneId::Scrollback];
/// The console input is always an Emacs-style line editor, regardless
/// of the app's edit mode. The REPL has no vim-normal routing or
/// two-stage Esc, so building it in Vim mode (reachable at runtime via
/// `Ctrl-V`) would strand it in an unhandled normal mode where motion
/// keys insert literally; pinning Emacs keeps the input always usable.
#[must_use]
pub fn new() -> Self {
Self {
input: Editor::new(EditMode::Emacs),
pending: String::new(),
scrollback: Vec::new(),
history: Vec::new(),
history_cursor: None,
panes: PaneSet::new(Self::PANES, PaneId::Prompt),
scroll: 0,
/// Consume the current input line. If, together with any pending
/// lines, it forms a balanced nomiscript form, return that complete
/// form string, clear the buffers, and record it in `history`. An
/// unbalanced (incomplete) form is appended to `pending` and `None`
/// is returned, so the caller keeps collecting lines. A blank /
/// whitespace-only candidate yields `None` without echoing or
/// recording history (a bare Enter must not submit an empty form).
pub fn take_complete_form(&mut self) -> Option<String> {
let line = self.input.buffer().to_string();
self.input = Editor::new(self.input.mode());
let candidate = match self.pending.is_empty() {
true => line,
false => format!("{}\n{}", self.pending, line),
};
if candidate.trim().is_empty() {
return None;
match Reader::is_incomplete(&candidate) {
true => {
self.pending = candidate;
None
false => {
self.pending.clear();
self.push_history(candidate.clone());
self.history_cursor = None;
Some(candidate)
/// Record a submitted form, dropping the oldest entries once the
/// retained count would exceed [`MAX_HISTORY_ENTRIES`].
fn push_history(&mut self, form: String) {
self.history.push(form);
let overflow = self.history.len().saturating_sub(MAX_HISTORY_ENTRIES);
if overflow > 0 {
self.history.drain(0..overflow);
/// Move the history cursor toward older entries, loading the entry
/// at the new cursor into the input editor. A no-op on empty history;
/// once the cursor reaches the oldest entry it stays there.
pub fn history_prev(&mut self) {
if self.history.is_empty() {
return;
let next = match self.history_cursor {
None => self.history.len() - 1,
Some(0) => 0,
Some(i) => i - 1,
self.history_cursor = Some(next);
self.load_history_entry();
/// Move the history cursor toward newer entries, loading the entry
/// into the input editor. Stepping past the newest entry clears the
/// cursor and the input line (a fresh prompt).
pub fn history_next(&mut self) {
let Some(cursor) = self.history_cursor else {
match cursor + 1 < self.history.len() {
self.history_cursor = Some(cursor + 1);
/// Replace the input editor with the history entry at the current
/// cursor. Caller guarantees the cursor points at a valid index.
fn load_history_entry(&mut self) {
if let Some(entry) = self.history_cursor.and_then(|i| self.history.get(i)) {
self.input = Editor::with_buffer(self.input.mode(), entry.clone());
/// Append a line to the scrollback, dropping the oldest lines once
/// the retained count would exceed [`MAX_SCROLLBACK_LINES`].
pub fn push_scrollback(&mut self, line: impl Into<String>) {
self.scrollback.push(line.into());
let overflow = self.scrollback.len().saturating_sub(MAX_SCROLLBACK_LINES);
self.scrollback.drain(0..overflow);
/// Scroll up by `n` lines (toward older content). Clamped to the
/// scrollback length so the offset can never exceed the buffer size.
pub fn scroll_up(&mut self, n: usize) {
self.scroll = (self.scroll + n).min(self.scrollback.len());
/// Scroll down by `n` lines (toward newer content). Saturates at 0.
pub fn scroll_down(&mut self, n: usize) {
self.scroll = self.scroll.saturating_sub(n);
/// Reset the scroll offset to the bottom (newest line visible).
pub fn reset_scroll(&mut self) {
self.scroll = 0;
/// The slice of scrollback lines that fit in a viewport of `height` rows,
/// respecting the current scroll offset. The effective offset is clamped
/// to the viewport (`len - height`) so the visible window is never emptied
/// past the oldest line, even when the stored offset exceeds that bound.
pub fn visible_scrollback(&self, height: usize) -> &[String] {
let len = self.scrollback.len();
let max_scroll = len.saturating_sub(height);
let scroll = self.scroll.min(max_scroll);
let end = len - scroll;
let start = end.saturating_sub(height);
&self.scrollback[start..end]
/// Pad each column in `rows` to the maximum width in that column and
/// join cells with two spaces. Empty `rows` yields an empty `Vec`.
///
/// Width is measured in Unicode scalars (`chars().count()`); wide (CJK) or
/// zero-width characters in account/commodity names can therefore misalign
/// columns slightly. Acceptable for the Console scrollback — the dedicated
/// data tabs render through ratatui's own width-aware `Table`.
pub fn align_rows(rows: &[Vec<String>]) -> Vec<String> {
if rows.is_empty() {
return vec![];
let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let widths: Vec<usize> = (0..col_count)
.map(|c| {
rows.iter()
.map(|r| r.get(c).map_or(0, |s| s.chars().count()))
.max()
.unwrap_or(0)
})
.collect();
.map(|row| {
row.iter()
.enumerate()
.map(|(c, cell)| {
let w = widths.get(c).copied().unwrap_or(0);
format!("{cell:<w$}", w = w)
.collect::<Vec<_>>()
.join(" ")
.trim_end()
.to_string()
.collect()
/// Tabulate `s` when it is a non-empty proper list — the shape every list
/// command returns, including `pair:string` results like `get-balances` whose
/// elements are plist strings. The Console is typeless (unlike the CLI's
/// per-command `RenderMode` and the dedicated data tabs), so this is
/// best-effort: an empty list or a non-list renders verbatim, and a rare scalar
/// string that happens to read as a list is shown as rows. `value_to_rows`
/// owns the per-element formatting, exactly as the CLI list renderers do.
fn render_list_value(s: &str) -> Option<Vec<String>> {
let trimmed = s.trim_start();
if !trimmed.starts_with('(') {
let value = reparse_list(trimmed).ok()?;
if list_to_vec(&value).is_none_or(|els| els.is_empty()) {
let rows = value_to_rows(&value);
Some(align_rows(&rows))
/// Render an RPC response envelope into scrollback lines.
/// Structured responses: list payloads → aligned table, scalars → one
/// line, server errors → `[error] code: message`. Anything that does
/// not parse as a response envelope (request frames, plain notices)
/// falls back to verbatim line split.
pub fn format_result(envelope: &str) -> Vec<String> {
match parse_wire(envelope) {
Ok(WireValue::Value(Value::String(ref s))) => {
render_list_value(s).unwrap_or_else(|| vec![s.clone()])
Ok(WireValue::Value(ref v)) => {
vec![format_value(v)]
Err(RenderError::Server { code, message }) => {
vec![format!("[error] {code}: {message}")]
Err(_) => envelope.lines().map(str::to_string).collect(),
#[cfg(test)]
mod tests;