Add TUI frontend and syntax highlighting (REPL and web UI)

This commit is contained in:
Hassan Abedi 2026-04-14 10:16:41 +02:00
parent c3a1c7d9dd
commit d7b2eb4144
9 changed files with 689 additions and 12 deletions

View File

@ -55,7 +55,7 @@ Quick examples:
- `inference.rs`: shared matching, negation filtering, and provenance-aware materialization helpers.
- `stratification.rs`: stratification analysis for rules with negation.
- `union_find.rs`: equality merging support.
- `src/frontend/`: lightweight interactive surface for scripts, REPL, and local web UI.
- `src/frontend/`: lightweight interactive surface for scripts, REPL, local web UI, TUI (behind `tui` feature), and syntax highlighting.
- `src/relational/`: schemas, values, rows, and result sets for relational execution.
- `src/catalog/`: predicate-to-table schema inference and catalog access.
- `src/io/`: CSV-based fact import and export.

View File

@ -29,8 +29,11 @@ path = "src/main.rs"
[features]
default = [] # No features enabled by default
binaries = []
tui = ["dep:ratatui", "dep:crossterm"]
[dependencies]
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true }
[dev-dependencies]
proptest = "1.6"

View File

@ -13,7 +13,7 @@ execution boundaries.
- Restricted, standard, oblivious, and Skolem chase variants
- Optional semi-naive evaluation across all chase variants
- Provenance-oriented explanations for derived answers
- Script, REPL, and local web UI for experimentation
- Script, REPL, local web UI, and optional TUI for experimentation (all with syntax highlighting)
- Relational schema, catalog, logical-plan, and execution scaffolding
- Physical operator scaffolding with a small rule-based rewrite layer
- A minimal SQL slice for `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` queries over predicate-backed tables, including `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates
@ -99,6 +99,7 @@ cargo run -- repl
cargo run -- gui
cargo run -- script examples/scripts/ancestor.ech
cargo run -- script examples/scripts/sql_join.ech
cargo run --features tui -- tui
```
#### REPL language

310
src/frontend/highlight.rs Normal file
View File

@ -0,0 +1,310 @@
//! Token-based syntax highlighting for the chase language and SQL.
//!
//! This module tokenizes input lines and classifies each span so that
//! frontends (REPL, TUI, web) can apply their own styling. Two convenience
//! renderers are provided out of the box:
//!
//! - [`to_ansi`]: renders highlighted text with ANSI escape codes for
//! terminal output.
//! - [`HighlightToken`]: structured tokens that TUI and web frontends can
//! map to their native style types.
/// The semantic role of a highlighted token.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
/// A chase command keyword: `fact`, `rule`, `run`, `query`, `explain`,
/// `show`, `reset`, `help`, `schema`, `sql`.
Command,
/// A SQL keyword: `SELECT`, `FROM`, `WHERE`, `GROUP`, `BY`, `ORDER`,
/// `LIMIT`, `AS`, `AND`, `OR`, `ASC`, `DESC`, `NULL`.
SqlKeyword,
/// An aggregate function name: `COUNT`, `SUM`, `MIN`, `MAX`, `AVG`.
Function,
/// A variable reference such as `?X`.
Variable,
/// A string literal enclosed in single quotes.
StringLiteral,
/// A numeric literal.
Number,
/// An operator: `=`, `!=`, `<>`, `->`, `,`, `.`, `(`, `)`, `*`, `;`, `?`.
Operator,
/// A predicate or identifier name.
Identifier,
/// Whitespace or anything that does not fit another category.
Plain,
/// A comment line starting with `#`.
Comment,
}
/// A single highlighted token with its text content and kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HighlightToken {
pub text: String,
pub kind: TokenKind,
}
impl HighlightToken {
fn new(text: impl Into<String>, kind: TokenKind) -> Self {
Self {
text: text.into(),
kind,
}
}
}
/// Tokenize a single line into highlighted tokens.
pub fn highlight_line(line: &str) -> Vec<HighlightToken> {
let trimmed = line.trim();
if trimmed.starts_with('#') {
return vec![HighlightToken::new(line, TokenKind::Comment)];
}
let mut tokens = Vec::new();
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
let mut i = 0;
let mut is_first_word = true;
while i < len {
let ch = chars[i];
// Whitespace
if ch.is_whitespace() {
let start = i;
while i < len && chars[i].is_whitespace() {
i += 1;
}
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Plain));
continue;
}
// String literal
if ch == '\'' {
let start = i;
i += 1;
while i < len {
if chars[i] == '\'' {
i += 1;
if i < len && chars[i] == '\'' {
i += 1; // escaped quote
} else {
break;
}
} else {
i += 1;
}
}
tokens.push(HighlightToken::new(
&line[start..i],
TokenKind::StringLiteral,
));
continue;
}
// Multi-char operators
if ch == '-' && i + 1 < len && chars[i + 1] == '>' {
tokens.push(HighlightToken::new("->", TokenKind::Operator));
i += 2;
continue;
}
if ch == '!' && i + 1 < len && chars[i + 1] == '=' {
tokens.push(HighlightToken::new("!=", TokenKind::Operator));
i += 2;
continue;
}
if ch == '<' && i + 1 < len && chars[i + 1] == '>' {
tokens.push(HighlightToken::new("<>", TokenKind::Operator));
i += 2;
continue;
}
// Variable (?X) — must be checked before the single-char operator set
// since `?` also appears there for standalone query terminators.
if ch == '?' && i + 1 < len && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_') {
let start = i;
i += 1;
while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Variable));
continue;
}
// Number
if ch.is_ascii_digit() {
let start = i;
while i < len && chars[i].is_ascii_digit() {
i += 1;
}
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Number));
continue;
}
// Word (identifier or keyword)
if ch.is_alphabetic() || ch == '_' {
let start = i;
while i < len && (chars[i].is_alphanumeric() || "_-:.".contains(chars[i])) {
i += 1;
}
let word = &line[start..i];
let kind = classify_word(word, is_first_word);
tokens.push(HighlightToken::new(word, kind));
is_first_word = false;
continue;
}
// Fallback
tokens.push(HighlightToken::new(
&line[i..i + ch.len_utf8()],
TokenKind::Plain,
));
i += ch.len_utf8();
}
tokens
}
fn classify_word(word: &str, is_first_word: bool) -> TokenKind {
let upper = word.to_ascii_uppercase();
// Chase commands (only when they are the first word on a line).
if is_first_word {
match upper.as_str() {
"FACT" | "RULE" | "RUN" | "QUERY" | "EXPLAIN" | "SHOW" | "RESET" | "HELP"
| "SCHEMA" | "SQL" => return TokenKind::Command,
_ => {}
}
}
// SQL keywords
match upper.as_str() {
"SELECT" | "FROM" | "WHERE" | "GROUP" | "BY" | "ORDER" | "LIMIT" | "AS" | "AND" | "OR"
| "ASC" | "DESC" | "NULL" | "NOT" | "ON" | "JOIN" | "INNER" | "LEFT" | "RIGHT"
| "OUTER" | "HAVING" | "DISTINCT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP"
| "TABLE" | "INTO" | "VALUES" | "SET" => {
return TokenKind::SqlKeyword;
}
_ => {}
}
// Aggregate / function names
match upper.as_str() {
"COUNT" | "SUM" | "MIN" | "MAX" | "AVG" => return TokenKind::Function,
_ => {}
}
// Chase-language keywords appearing mid-line
match upper.as_str() {
"FACTS" | "RULES" => return TokenKind::Command,
_ => {}
}
TokenKind::Identifier
}
// ---------------------------------------------------------------------------
// ANSI rendering
// ---------------------------------------------------------------------------
const ANSI_RESET: &str = "\x1b[0m";
const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_CYAN: &str = "\x1b[36m";
const ANSI_BLUE: &str = "\x1b[34m";
const ANSI_GREEN: &str = "\x1b[32m";
const ANSI_YELLOW: &str = "\x1b[33m";
const ANSI_MAGENTA: &str = "\x1b[35m";
const ANSI_RED: &str = "\x1b[31m";
const ANSI_GRAY: &str = "\x1b[90m";
fn ansi_for(kind: TokenKind) -> &'static str {
match kind {
TokenKind::Command => ANSI_BOLD,
TokenKind::SqlKeyword => ANSI_BLUE,
TokenKind::Function => ANSI_CYAN,
TokenKind::Variable => ANSI_MAGENTA,
TokenKind::StringLiteral => ANSI_GREEN,
TokenKind::Number => ANSI_YELLOW,
TokenKind::Operator => ANSI_RED,
TokenKind::Identifier => "",
TokenKind::Plain => "",
TokenKind::Comment => ANSI_GRAY,
}
}
/// Render a line with ANSI escape codes for terminal display.
pub fn to_ansi(line: &str) -> String {
let tokens = highlight_line(line);
let mut out = String::with_capacity(line.len() + 64);
for token in &tokens {
let code = ansi_for(token.kind);
if code.is_empty() {
out.push_str(&token.text);
} else {
out.push_str(code);
out.push_str(&token.text);
out.push_str(ANSI_RESET);
}
}
out
}
/// Render multiple lines with ANSI highlighting.
pub fn to_ansi_block(text: &str) -> String {
text.lines().map(to_ansi).collect::<Vec<_>>().join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highlights_chase_command() {
let tokens = highlight_line("fact Parent(alice, bob).");
assert_eq!(tokens[0].kind, TokenKind::Command);
assert_eq!(tokens[0].text, "fact");
}
#[test]
fn highlights_sql_keywords_and_function() {
let tokens = highlight_line("sql SELECT COUNT(*) FROM Parent WHERE c0 = 'alice';");
let kinds: Vec<TokenKind> = tokens.iter().map(|t| t.kind).collect();
assert!(kinds.contains(&TokenKind::Command)); // sql
assert!(kinds.contains(&TokenKind::SqlKeyword)); // SELECT, FROM, WHERE
assert!(kinds.contains(&TokenKind::Function)); // COUNT
assert!(kinds.contains(&TokenKind::StringLiteral)); // 'alice'
}
#[test]
fn highlights_variables() {
let tokens = highlight_line("rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).");
let var_tokens: Vec<&HighlightToken> = tokens
.iter()
.filter(|t| t.kind == TokenKind::Variable)
.collect();
assert_eq!(var_tokens.len(), 4);
assert_eq!(var_tokens[0].text, "?X");
}
#[test]
fn highlights_comment() {
let tokens = highlight_line("# this is a comment");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].kind, TokenKind::Comment);
}
#[test]
fn highlights_operators() {
let tokens = highlight_line("a != b");
let op = tokens
.iter()
.find(|t| t.kind == TokenKind::Operator)
.unwrap();
assert_eq!(op.text, "!=");
}
#[test]
fn ansi_output_contains_escape_codes() {
let ansi = to_ansi("fact Parent(alice, bob).");
assert!(ansi.contains("\x1b["));
}
}

View File

@ -1,9 +1,11 @@
//! Frontend utilities for interacting with the query-engine playground.
pub mod highlight;
pub mod language;
pub mod provenance;
pub mod repl;
pub mod session;
pub mod tui;
pub mod web;
pub use repl::run_repl;

View File

@ -1,13 +1,18 @@
//! Interactive REPL for the minimal query-engine frontend language.
//!
//! Output is syntax-highlighted with ANSI escape codes when the terminal
//! supports them.
use std::io::{self, BufRead, Write};
use std::io::{self, BufRead, IsTerminal, Write};
use super::Session;
use super::highlight::to_ansi_block;
pub fn run_repl() -> io::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut session = Session::new();
let color = stdout.is_terminal();
writeln!(stdout, "query-engine REPL")?;
writeln!(stdout, "Type `help` for commands and `quit` to exit.")?;
@ -32,8 +37,14 @@ pub fn run_repl() -> io::Result<()> {
}
match session.execute_script(trimmed) {
Ok(output) => writeln!(stdout, "{}", output)?,
Err(err) => writeln!(stdout, "error: {}", err)?,
Ok(output) => {
if color {
writeln!(stdout, "{}", to_ansi_block(&output))?;
} else {
writeln!(stdout, "{}", output)?;
}
}
Err(err) => writeln!(stdout, "\x1b[31merror: {}\x1b[0m", err)?,
}
}
}

274
src/frontend/tui.rs Normal file
View File

@ -0,0 +1,274 @@
//! Terminal user interface built on `ratatui` and `crossterm`.
//!
//! The TUI provides a two-pane layout: a scrollable output area on top and
//! an input line at the bottom. Input is syntax-highlighted as the user
//! types, and command history is navigable with the up/down arrow keys.
//!
//! Enable with `cargo run --features tui -- tui`.
#[cfg(feature = "tui")]
pub mod app {
use std::io::{self, Stdout};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::frontend::highlight::{HighlightToken, TokenKind, highlight_line};
use crate::frontend::session::Session;
struct TuiState {
session: Session,
input: String,
cursor: usize,
output_lines: Vec<String>,
scroll: u16,
history: Vec<String>,
history_index: Option<usize>,
}
impl TuiState {
fn new() -> Self {
Self {
session: Session::new(),
input: String::new(),
cursor: 0,
output_lines: vec!["Welcome to query-engine TUI. Type `help` for commands.".into()],
scroll: 0,
history: Vec::new(),
history_index: None,
}
}
fn submit(&mut self) {
let line = self.input.clone();
if line.trim().is_empty() {
return;
}
self.output_lines.push(format!("chase> {}", line));
self.history.push(line.clone());
self.history_index = None;
if line.trim() == "quit" || line.trim() == "exit" {
return;
}
let response = self
.session
.execute_script(&line)
.unwrap_or_else(|e| format!("error: {}", e));
for out_line in response.lines() {
self.output_lines.push(out_line.to_string());
}
self.input.clear();
self.cursor = 0;
// Auto-scroll to bottom.
let total = self.output_lines.len() as u16;
self.scroll = total.saturating_sub(1);
}
fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
let new_index = match self.history_index {
None => self.history.len() - 1,
Some(0) => 0,
Some(i) => i - 1,
};
self.history_index = Some(new_index);
self.input = self.history[new_index].clone();
self.cursor = self.input.len();
}
fn history_down(&mut self) {
match self.history_index {
None => {}
Some(i) if i + 1 >= self.history.len() => {
self.history_index = None;
self.input.clear();
self.cursor = 0;
}
Some(i) => {
self.history_index = Some(i + 1);
self.input = self.history[i + 1].clone();
self.cursor = self.input.len();
}
}
}
}
fn token_style(kind: TokenKind) -> Style {
match kind {
TokenKind::Command => Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
TokenKind::SqlKeyword => Style::default().fg(Color::Blue),
TokenKind::Function => Style::default().fg(Color::Cyan),
TokenKind::Variable => Style::default().fg(Color::Magenta),
TokenKind::StringLiteral => Style::default().fg(Color::Green),
TokenKind::Number => Style::default().fg(Color::Yellow),
TokenKind::Operator => Style::default().fg(Color::Red),
TokenKind::Identifier => Style::default(),
TokenKind::Plain => Style::default(),
TokenKind::Comment => Style::default().fg(Color::DarkGray),
}
}
fn tokens_to_line(tokens: &[HighlightToken]) -> Line<'static> {
let spans: Vec<Span<'static>> = tokens
.iter()
.map(|t| Span::styled(t.text.clone(), token_style(t.kind)))
.collect();
Line::from(spans)
}
fn draw(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: &TuiState) -> io::Result<()> {
terminal.draw(|frame| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(frame.area());
// Output pane
let output_lines: Vec<Line<'static>> = state
.output_lines
.iter()
.map(|l| tokens_to_line(&highlight_line(l)))
.collect();
let output_height = chunks[0].height.saturating_sub(2);
let max_scroll = (output_lines.len() as u16).saturating_sub(output_height);
let scroll = state.scroll.min(max_scroll);
let output = Paragraph::new(output_lines)
.block(
Block::default()
.title(" Output ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
frame.render_widget(output, chunks[0]);
// Input pane
let input_line = tokens_to_line(&highlight_line(&state.input));
let input = Paragraph::new(input_line).block(
Block::default()
.title(" chase> ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(input, chunks[1]);
// Place cursor
#[allow(clippy::cast_possible_truncation)]
let cursor_x = chunks[1].x + 1 + state.cursor as u16;
let cursor_y = chunks[1].y + 1;
frame.set_cursor_position((cursor_x, cursor_y));
})?;
Ok(())
}
/// Run the TUI event loop.
pub fn run_tui() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut state = TuiState::new();
let result = run_loop(&mut terminal, &mut state);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
state: &mut TuiState,
) -> io::Result<()> {
loop {
draw(terminal, state)?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break;
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break;
}
KeyCode::Enter => {
let should_quit =
state.input.trim() == "quit" || state.input.trim() == "exit";
state.submit();
if should_quit {
break;
}
}
KeyCode::Char(c) => {
state.input.insert(state.cursor, c);
state.cursor += c.len_utf8();
}
KeyCode::Backspace => {
if state.cursor > 0 {
state.cursor -= 1;
state.input.remove(state.cursor);
}
}
KeyCode::Delete => {
if state.cursor < state.input.len() {
state.input.remove(state.cursor);
}
}
KeyCode::Left => {
if state.cursor > 0 {
state.cursor -= 1;
}
}
KeyCode::Right => {
if state.cursor < state.input.len() {
state.cursor += 1;
}
}
KeyCode::Home => {
state.cursor = 0;
}
KeyCode::End => {
state.cursor = state.input.len();
}
KeyCode::Up => {
state.history_up();
}
KeyCode::Down => {
state.history_down();
}
KeyCode::PageUp => {
state.scroll = state.scroll.saturating_sub(10);
}
KeyCode::PageDown => {
state.scroll = state.scroll.saturating_add(10);
}
_ => {}
}
}
}
Ok(())
}
}

View File

@ -101,7 +101,7 @@ fn http_response(status: &str, content_type: &str, body: &str) -> String {
)
}
const INDEX_HTML: &str = r#"<!DOCTYPE html>
const INDEX_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@ -218,7 +218,9 @@ const INDEX_HTML: &str = r#"<!DOCTYPE html>
pre {
margin: 0;
padding: 1.25rem;
min-height: 100%;
min-height: 22rem;
max-height: 70vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
@ -265,22 +267,90 @@ explain Ancestor(alice, carol)?</textarea>
const output = document.getElementById("output");
const script = document.getElementById("script");
const CMD_RE = /^(fact|rule|run|query|explain|show|reset|help|schema|sql)\b/i;
const SQL_KW = /^(SELECT|FROM|WHERE|GROUP|BY|ORDER|LIMIT|AS|AND|OR|ASC|DESC|NULL|NOT|ON|JOIN|HAVING|DISTINCT)\b/i;
const FUNC_RE = /^(COUNT|SUM|MIN|MAX|AVG)\b/i;
const VAR_RE = /^\?[A-Za-z_]\w*/;
const NUM_RE = /^\d+/;
const STR_RE = /^'(?:[^']|'')*'/;
const OP_RE = /^(->|!=|<>|[=,.*();?])/;
const COLORS = {
command: "#fff; font-weight:bold",
sqlkw: "#5599ff",
func: "#00bbbb",
variable: "#cc66ff",
string: "#44bb44",
number: "#ddaa00",
operator: "#cc4444",
comment: "#888",
};
function highlightLine(line) {
if (line.trimStart().startsWith("#"))
return `<span style="color:${COLORS.comment}">${esc(line)}</span>`;
let out = "", rest = line, first = true;
while (rest.length > 0) {
let m;
if ((m = rest.match(/^\s+/))) {
out += m[0]; rest = rest.slice(m[0].length); continue;
}
if ((m = rest.match(STR_RE))) {
out += `<span style="color:${COLORS.string}">${esc(m[0])}</span>`;
rest = rest.slice(m[0].length); continue;
}
if ((m = rest.match(OP_RE))) {
out += `<span style="color:${COLORS.operator}">${esc(m[0])}</span>`;
rest = rest.slice(m[0].length); continue;
}
if ((m = rest.match(VAR_RE))) {
out += `<span style="color:${COLORS.variable}">${esc(m[0])}</span>`;
rest = rest.slice(m[0].length); first = false; continue;
}
if ((m = rest.match(NUM_RE))) {
out += `<span style="color:${COLORS.number}">${esc(m[0])}</span>`;
rest = rest.slice(m[0].length); first = false; continue;
}
if ((m = rest.match(/^[A-Za-z_][\w\-:.]*/))) {
let word = m[0], color = null;
if (first && CMD_RE.test(word)) color = COLORS.command;
else if (SQL_KW.test(word)) color = COLORS.sqlkw;
else if (FUNC_RE.test(word)) color = COLORS.func;
if (color) out += `<span style="color:${color}">${esc(word)}</span>`;
else out += esc(word);
rest = rest.slice(word.length); first = false; continue;
}
out += esc(rest[0]); rest = rest.slice(1);
}
return out;
}
function esc(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function highlightBlock(text) {
return text.split("\n").map(highlightLine).join("\n");
}
async function send(path, body) {
const response = await fetch(path, { method: "POST", body });
const text = await response.text();
return text;
return await response.text();
}
document.getElementById("execute").addEventListener("click", async () => {
const text = await send("/execute", script.value);
output.textContent += `\n\n> ${new Date().toLocaleTimeString()}\n${text}`;
const ts = new Date().toLocaleTimeString();
const highlighted = highlightBlock(text);
output.innerHTML += `\n\n<span style="color:${COLORS.comment}">&gt; ${esc(ts)}</span>\n${highlighted}`;
output.scrollTop = output.scrollHeight;
});
document.getElementById("reset").addEventListener("click", async () => {
const text = await send("/reset", "");
output.textContent = text;
output.innerHTML = esc(text);
});
</script>
</body>
</html>
"#;
"##;

View File

@ -15,6 +15,8 @@ fn main() -> io::Result<()> {
[cmd] if cmd.eq_ignore_ascii_case("gui") => serve_gui("127.0.0.1:7878"),
[cmd, address] if cmd.eq_ignore_ascii_case("gui") => serve_gui(address),
[cmd, path] if cmd.eq_ignore_ascii_case("script") => run_script(path),
#[cfg(feature = "tui")]
[cmd] if cmd.eq_ignore_ascii_case("tui") => query_engine::frontend::tui::app::run_tui(),
_ => {
print_usage();
Ok(())
@ -39,4 +41,8 @@ fn print_usage() {
println!(" query-engine repl");
println!(" query-engine gui [host:port]");
println!(" query-engine script <path>");
#[cfg(feature = "tui")]
println!(" query-engine tui");
#[cfg(not(feature = "tui"))]
println!(" query-engine tui (requires --features tui)");
}