#![allow(dead_code)]
use std::io;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command as ProcCommand, Stdio};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use crate::command::{CommandRegistry, RenderBlock};
use crate::tui::input::Prompt;
use crate::tui::palette::Palette;
use crate::tui::scrollback::Scrollback;
use crate::tui::statusline::Mode;
use crate::tui::theme::Theme;
const INTERACTIVE_COMMANDS: &[&str] = &["attach", "shell", "repl"];
const EXTRA_RUNNABLE: &[&str] = &[
"down",
"dist",
"vars",
"push",
"context",
"build",
"logs",
"nodes",
"servers",
"rmi",
"connect",
"setup",
"launch",
"add_worker",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkMode {
Local,
P2p,
}
impl NetworkMode {
fn label(self) -> &'static str {
match self {
NetworkMode::Local => "local",
NetworkMode::P2p => "p2p",
}
}
}
enum CmdMsg {
Line(String),
Done(String),
}
fn logo_lines(_theme: &Theme) -> Vec<Line<'static>> {
let bordeaux = Color::Rgb(0x6B, 0x0F, 0x1A);
let body = Style::default().fg(bordeaux);
let dot = Style::default().fg(bordeaux).add_modifier(Modifier::BOLD);
let rows: &[&str] = &[
" ╷",
" ┌──┴───┐",
"○──┤ ● ● ├─○",
" │ ● │",
" └───┬──┘",
" ○",
];
let mut out = Vec::with_capacity(rows.len());
for text in rows {
let spans: Vec<Span<'static>> = text
.chars()
.map(|ch| {
if ch == '●' {
Span::styled(ch.to_string(), dot)
} else {
Span::styled(ch.to_string(), body)
}
})
.collect();
out.push(Line::from(spans));
}
out
}
fn sanitize_stream_line(line: &str) -> String {
let chars: Vec<char> = line.chars().collect();
let mut out = String::with_capacity(chars.len());
let mut i = 0;
let mut col = 0usize;
while i < chars.len() {
let c = chars[i];
if c == '\x1b' {
i += 1;
if i < chars.len() && chars[i] == '[' {
i += 1;
while i < chars.len() && !('@'..='~').contains(&chars[i]) {
i += 1;
}
i += 1; } else if i < chars.len() && chars[i] == ']' {
i += 1;
while i < chars.len() && chars[i] != '\x07' && chars[i] != '\x1b' {
i += 1;
}
i += 1;
}
continue;
}
if c == '\t' {
let pad = 4 - (col % 4);
for _ in 0..pad {
out.push(' ');
}
col += pad;
i += 1;
continue;
}
if (c as u32) < 0x20 {
i += 1;
continue;
}
out.push(c);
col += 1;
i += 1;
}
out
}
fn user_id_from_key(key: &str) -> Option<String> {
key.strip_prefix("zk_").and_then(|rest| {
rest.rfind('_').and_then(|pos| {
let uid = &rest[..pos];
if uid.is_empty() {
None
} else {
Some(uid.to_string())
}
})
})
}
trait MeshControl {
fn ensure(&self) -> Result<crate::vpn::MeshAccess, crate::vpn::NetError>;
fn disconnect(&self) -> Result<(), crate::vpn::NetError>;
}
struct HostMesh;
impl MeshControl for HostMesh {
fn ensure(&self) -> Result<crate::vpn::MeshAccess, crate::vpn::NetError> {
crate::vpn::ensure(crate::vpn::connector::Preference::Auto)
}
fn disconnect(&self) -> Result<(), crate::vpn::NetError> {
crate::vpn::disconnect()
}
}
pub struct ShellApp {
theme: Theme,
registry: CommandRegistry,
scrollback: Scrollback,
prompt: Prompt,
palette: Palette,
mode: Mode,
running: bool,
authenticated: bool,
net_mode: NetworkMode,
cmd_rx: Option<Receiver<CmdMsg>>,
cmd_child: Option<std::process::Child>,
spin: usize,
mesh: Box<dyn MeshControl>,
}
impl ShellApp {
pub fn new() -> Self {
let authed = std::env::var("ZAKURO_API_KEY")
.map(|k| !k.trim().is_empty())
.unwrap_or(false);
Self::with_authenticated(authed)
}
fn with_authenticated(authed: bool) -> Self {
Self::with_mesh(authed, Box::new(HostMesh))
}
fn with_mesh(authed: bool, mesh: Box<dyn MeshControl>) -> Self {
let theme = Theme::dark();
let prompt = Prompt::new(&theme);
let mut app = ShellApp {
theme,
registry: CommandRegistry::with_builtins(),
scrollback: Scrollback::new(),
prompt,
palette: Palette::new(),
mode: Mode::Normal,
running: true,
authenticated: authed,
net_mode: NetworkMode::Local,
cmd_rx: None,
cmd_child: None,
spin: 0,
mesh,
};
app.greet();
app
}
fn cmd_mode(&mut self, which: Option<&str>) {
match which {
None => {
self.scrollback.push(RenderBlock::Markdown(format!(
"Current mode: **{}**.\n\n\
- `/mode local` — run against the local broker (no auth)\n\
- `/mode p2p` — join the peer network (requires `ZAKURO_API_KEY`)",
self.net_mode.label()
)));
}
Some("local") => {
self.net_mode = NetworkMode::Local;
let _ = self.mesh.disconnect();
self.scrollback.push(RenderBlock::Text(
"Switched to local mode — disconnected from the mesh, no auth required."
.to_string(),
));
}
Some("p2p") => {
if self.authenticated {
match self.mesh.ensure() {
Ok(access) => {
self.net_mode = NetworkMode::P2p;
let how = match &access {
crate::vpn::MeshAccess::Host => "host tunnel".to_string(),
crate::vpn::MeshAccess::Proxy(p) => format!("container proxy {p}"),
};
self.scrollback.push(RenderBlock::Markdown(format!(
"Switched to **p2p** mode — mesh verified via {how}."
)));
}
Err(e) => {
self.scrollback.push(RenderBlock::Error(format!(
"staying in {} mode — mesh not reachable: {}",
self.net_mode.label(),
e
)));
}
}
} else {
self.prompt_for_auth();
}
}
Some(other) => {
self.scrollback.push(RenderBlock::Error(format!(
"unknown mode: {} — use `local` or `p2p`",
other
)));
}
}
}
fn cmd_discovery(&mut self, broker_arg: Option<&str>) {
let env_broker = std::env::var("ZAKURO_BROKER").ok();
let base = if crate::autobroker::should_auto_spawn(broker_arg, env_broker.as_deref()) {
match crate::autobroker::ensure_local_broker(true) {
Ok(u) => u,
Err(e) => {
self.scrollback
.push(RenderBlock::Error(format!("discovery: {}", e)));
return;
}
}
} else {
let raw = broker_arg
.map(|s| s.to_string())
.or(env_broker)
.unwrap_or_else(|| "zc://localhost".to_string());
match crate::broker::uri::resolve(&raw) {
Ok(u) => u,
Err(e) => {
self.scrollback.push(RenderBlock::Error(format!(
"discovery: bad broker '{}': {}",
raw, e
)));
return;
}
}
};
let url = format!("{}/workers", base.trim_end_matches('/'));
let json = ureq::get(&url)
.config()
.timeout_global(Some(Duration::from_secs(5)))
.build()
.call()
.ok()
.and_then(|r| r.into_body().read_to_string().ok())
.and_then(|b| serde_json::from_str::<serde_json::Value>(&b).ok());
let workers = match json {
Some(j) => j["workers"].as_array().cloned().unwrap_or_default(),
None => {
self.scrollback.push(RenderBlock::Error(format!(
"discovery: broker unreachable at {} — is it running?",
base
)));
return;
}
};
if workers.is_empty() {
self.scrollback.push(RenderBlock::Markdown(format!(
"**Discovery** — no workers registered at `{}`.",
base
)));
return;
}
let mut body = format!(
"{:<8} {:<9} {:<24} {:>4} {:>8}\n",
"NODE", "STATUS", "URI", "CPU", "MEM"
);
for w in &workers {
body.push_str(&format!(
"{:<8} {:<9} {:<24} {:>4.0} {:>7.1}G\n",
w["name"].as_str().unwrap_or("-"),
w["status"].as_str().unwrap_or("-"),
w["uri"].as_str().unwrap_or("-"),
w["cpus_available"].as_f64().unwrap_or(0.0),
w["memory_available_gib"].as_f64().unwrap_or(0.0),
));
}
self.scrollback.push(RenderBlock::Markdown(format!(
"## Discovery — {} worker(s) at `{}`\n\n```\n{}```",
workers.len(),
base,
body
)));
}
fn prompt_for_auth(&mut self) {
self.mode = Mode::Login;
self.scrollback.push(RenderBlock::Markdown(
"**p2p** mode needs authentication. Paste your `ZAKURO_API_KEY` below \
and press Enter (find it with `zc me`, or in the dashboard). Esc to cancel."
.to_string(),
));
self.prompt
.set_placeholder("Paste ZAKURO_API_KEY (zk_…) — Enter to confirm, Esc to cancel");
self.prompt.set_mask('•');
}
fn submit_login(&mut self) {
let key = self.prompt.text().trim().to_string();
self.prompt.clear();
if key.is_empty() {
self.scrollback.push(RenderBlock::Error(
"API key required — paste your ZAKURO_API_KEY and press Enter.".to_string(),
));
return; }
let user = match user_id_from_key(&key) {
Some(u) => u,
None => {
self.scrollback.push(RenderBlock::Error(
"that doesn't look like a valid key — expected the form \
`zk_<user>_…`. Try again."
.to_string(),
));
return; }
};
std::env::set_var("ZAKURO_API_KEY", &key);
self.authenticated = true;
self.net_mode = NetworkMode::P2p;
self.mode = Mode::Normal;
self.prompt.clear_mask();
self.prompt
.set_placeholder("Type a command, or / for the palette…");
self.scrollback.push(RenderBlock::Markdown(format!(
"Signed in as **{}** — switched to **p2p** mode. The key is set for this \
session; `export ZAKURO_API_KEY=…` to persist it.",
user
)));
}
fn cancel_login(&mut self) {
self.mode = Mode::Normal;
self.prompt.clear_mask();
self.prompt
.set_placeholder("Type a command, or / for the palette…");
self.prompt.clear();
self.scrollback.push(RenderBlock::Text(
"Cancelled — staying in local mode.".to_string(),
));
}
fn greet(&mut self) {
let accent = Style::default().fg(self.theme.accent);
let title = Style::default()
.fg(self.theme.fg)
.add_modifier(Modifier::BOLD);
let muted = Style::default().fg(self.theme.muted);
let version = env!("CARGO_PKG_VERSION");
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string())
.unwrap_or_default();
let mut banner: Vec<Line<'static>> = logo_lines(&self.theme);
const LOGO_W: usize = 13; const GAP: &str = " ";
let info: Vec<(usize, Vec<Span<'static>>)> = vec![
(
1,
vec![Span::styled(format!("zc shell v{}", version), title)],
),
(
2,
vec![Span::styled(
"Zakuro compute broker · interactive terminal",
muted,
)],
),
(3, vec![Span::styled(cwd, muted)]),
];
for (row, extra) in info {
if let Some(line) = banner.get_mut(row) {
let used: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
let pad = LOGO_W.saturating_sub(used);
line.spans
.push(Span::raw(format!("{}{}", " ".repeat(pad), GAP)));
line.spans.extend(extra);
}
}
banner.push(Line::from(""));
banner.push(Line::from(vec![
Span::styled("Tip: ", Style::default().fg(self.theme.warning)),
Span::styled("press ", muted),
Span::styled("/", accent.add_modifier(Modifier::BOLD)),
Span::styled(" for the palette · ", muted),
Span::styled("/help", accent),
Span::styled(" commands · ", muted),
Span::styled("/mode", accent),
Span::styled(" local↔p2p · ", muted),
Span::styled("/quit", accent),
Span::styled(" exit", muted),
]));
self.scrollback.push_raw(banner);
}
fn execute(&mut self, line: &str) {
self.scrollback.push_prompt_echo(line);
let body = line.trim().strip_prefix('/').unwrap_or(line.trim());
let mut parts = body.split_whitespace();
let name = match parts.next() {
Some(n) => n,
None => return,
};
let rest: Vec<&str> = parts.collect();
match name {
"help" => self.cmd_help(rest.first().copied()),
"clear" => self.scrollback.clear(),
"quit" | "exit" | "q" => self.running = false,
"shell" | "repl" => self.scrollback.push(RenderBlock::Text(
"You're already in the zc shell.".to_string(),
)),
"theme" => self.cmd_theme(rest.first().copied()),
"mode" => self.cmd_mode(rest.first().copied()),
"discovery" | "discover" => self.cmd_discovery(rest.first().copied()),
other => self.cmd_external(other, &rest),
}
}
fn cmd_help(&mut self, which: Option<&str>) {
if let Some(token) = which {
match self.registry.resolve(token) {
Some(spec) => {
let aliases = if spec.aliases.is_empty() {
String::new()
} else {
format!("\n\n_aliases: {}_", spec.aliases.join(", "))
};
let md = format!(
"## `{}`\n\n```\n{}\n```\n\n{}{}",
spec.name,
spec.usage(),
spec.help_md,
aliases
);
self.scrollback.push(RenderBlock::Markdown(md));
}
None => self
.scrollback
.push(RenderBlock::Error(format!("no such command: {}", token))),
}
return;
}
let mut md = String::from("## Commands\n\n");
for cat in crate::command::Category::order() {
let specs = self.registry.by_category(*cat);
if specs.is_empty() {
continue;
}
md.push_str(&format!("### {}\n\n", cat.title()));
for spec in specs {
md.push_str(&format!("- `{}` — {}\n", spec.usage(), spec.summary));
}
md.push('\n');
}
self.scrollback.push(RenderBlock::Markdown(md));
}
fn cmd_theme(&mut self, which: Option<&str>) {
match which {
None => {
let names = Theme::builtin_names().join(", ");
self.scrollback.push(RenderBlock::Markdown(format!(
"Current theme: **{}**. Available: {}.\n\nSwitch with `/theme <name>`.",
self.theme.name, names
)));
}
Some(name) => match Theme::named(name) {
Some(t) => {
self.theme = t;
self.prompt.retheme(&self.theme);
self.scrollback.push(RenderBlock::Text(format!(
"Theme switched to {}.",
self.theme.name
)));
}
None => self.scrollback.push(RenderBlock::Error(format!(
"unknown theme: {} (try: {})",
name,
Theme::builtin_names().join(", ")
))),
},
}
}
fn cmd_external(&mut self, name: &str, args: &[&str]) {
if INTERACTIVE_COMMANDS.contains(&name) {
self.scrollback.push(RenderBlock::Markdown(format!(
"`{}` opens a full-screen view that needs the whole terminal, so it \
can't run inside the shell. Run it directly:\n\n```\nzc {} {}\n```",
name,
name,
args.join(" ")
)));
return;
}
let runnable = self.registry.resolve(name).is_some() || EXTRA_RUNNABLE.contains(&name);
if !runnable {
self.scrollback.push(RenderBlock::Error(format!(
"unknown command: {} — type /help for the list",
name
)));
return;
}
let mut argv = vec![name.to_string()];
argv.extend(args.iter().map(|s| s.to_string()));
self.run_command(argv);
}
fn run_command(&mut self, argv: Vec<String>) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
self.scrollback.push(RenderBlock::Error(format!(
"cannot locate zc binary: {}",
e
)));
return;
}
};
let mut child = match ProcCommand::new(exe)
.args(&argv)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
self.scrollback.push(RenderBlock::Error(format!(
"failed to start command: {}",
e
)));
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (tx, rx) = mpsc::channel::<CmdMsg>();
thread::spawn(move || {
if let Some(out) = stdout {
let reader = BufReader::new(out);
for line in reader.lines() {
match line {
Ok(l) => {
if l.starts_with("DEBUG:") {
continue;
}
if tx.send(CmdMsg::Line(sanitize_stream_line(&l))).is_err() {
return;
}
}
Err(_) => break,
}
}
}
let mut errs = String::new();
if let Some(mut e) = stderr {
let _ = e.read_to_string(&mut errs);
}
let errs = errs
.lines()
.map(sanitize_stream_line)
.collect::<Vec<_>>()
.join("\n");
let _ = tx.send(CmdMsg::Done(errs));
});
self.cmd_child = Some(child);
self.cmd_rx = Some(rx);
self.mode = Mode::Busy;
self.spin = 0;
self.scrollback.begin_stream();
}
fn poll_command(&mut self) {
if self.cmd_rx.is_none() {
return;
}
self.spin = self.spin.wrapping_add(1);
let mut finished = false;
if let Some(rx) = &self.cmd_rx {
loop {
match rx.try_recv() {
Ok(CmdMsg::Line(l)) => self.scrollback.stream_line(&l),
Ok(CmdMsg::Done(errs)) => {
if !errs.trim().is_empty() {
self.scrollback
.push(RenderBlock::Error(errs.trim().to_string()));
}
finished = true;
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
finished = true;
break;
}
}
}
}
if finished {
if let Some(mut child) = self.cmd_child.take() {
let _ = child.wait();
}
self.cmd_rx = None;
self.mode = Mode::Normal;
}
}
fn interrupt_command(&mut self) {
if let Some(child) = &mut self.cmd_child {
let _ = child.kill();
}
self.scrollback
.push(RenderBlock::Error("^C interrupted".to_string()));
}
fn prompt_is_palette_query(&self) -> bool {
let t = self.prompt.text();
t.starts_with('/') && !t.trim_start_matches('/').contains(char::is_whitespace)
}
fn refresh_palette(&mut self) {
let t = self.prompt.text();
let query = t.trim_start_matches('/');
self.palette.update(&self.registry, query);
}
fn submit(&mut self) {
let line = self.prompt.submit();
if !line.is_empty() {
self.execute(&line);
}
}
fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) {
if self.mode == Mode::Busy {
self.interrupt_command();
} else {
self.running = false;
}
return;
}
match self.mode {
Mode::Login => self.handle_login_key(key),
Mode::Palette => self.handle_palette_key(key, alt),
Mode::Busy => {} Mode::Normal => self.handle_normal_key(key, alt),
}
}
fn handle_login_key(&mut self, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Enter => self.submit_login(),
KeyCode::Esc => self.cancel_login(),
_ => self.prompt.input(key),
}
}
fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent, alt: bool) {
match key.code {
KeyCode::Enter if alt => self.prompt.newline(),
KeyCode::Enter => self.submit(),
KeyCode::Char('/') if self.prompt.is_empty() => {
self.prompt.input(key);
self.mode = Mode::Palette;
self.refresh_palette();
}
KeyCode::PageUp => self.scrollback.scroll_up(5),
KeyCode::PageDown => self.scrollback.scroll_down(5),
KeyCode::Up if self.prompt.line_count() == 1 => self.prompt.history_prev(),
KeyCode::Down if self.prompt.line_count() == 1 => self.prompt.history_next(),
KeyCode::Esc => self.scrollback.scroll_to_bottom(),
_ => self.prompt.input(key),
}
}
fn handle_palette_key(&mut self, key: crossterm::event::KeyEvent, alt: bool) {
match key.code {
KeyCode::Esc => {
self.prompt.submit(); self.palette.reset();
self.mode = Mode::Normal;
}
KeyCode::Up => self.palette.select_prev(),
KeyCode::Down => self.palette.select_next(),
KeyCode::Tab => {
if let Some(name) = self.palette.selected_name() {
self.prompt.submit();
for ch in format!("{} ", name).chars() {
self.prompt.input(crossterm::event::KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::NONE,
));
}
}
self.palette.reset();
self.mode = Mode::Normal;
}
KeyCode::Enter if alt => {
self.prompt.newline();
self.mode = Mode::Normal;
}
KeyCode::Enter => {
let name = self.palette.selected_name().map(|s| s.to_string());
self.palette.reset();
self.mode = Mode::Normal;
match name {
Some(n) => {
self.prompt.submit();
self.execute(&n);
}
None => self.submit(),
}
}
_ => {
self.prompt.input(key);
if self.prompt_is_palette_query() {
self.refresh_palette();
} else {
self.palette.reset();
self.mode = Mode::Normal;
}
}
}
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
let input_h = (self.prompt.line_count() as u16).clamp(1, 6) + 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(input_h), Constraint::Length(1), ])
.split(area);
self.draw_scrollback(frame, chunks[0]);
self.draw_input(frame, chunks[1]);
self.draw_footer(frame, chunks[2]);
if self.mode == Mode::Palette && !self.palette.is_empty() {
self.palette
.render(frame, chunks[1], &self.registry, &self.theme);
}
}
fn draw_scrollback(&self, frame: &mut Frame, area: Rect) {
let lines = self.scrollback.to_lines(&self.theme);
let total = lines.len();
let offset = self.scrollback.offset_for(total, area.height);
let text = Text::from(lines);
let para = Paragraph::new(text)
.style(Style::default().fg(self.theme.fg))
.wrap(Wrap { trim: false })
.scroll((offset, 0));
frame.render_widget(para, area);
}
fn draw_input(&self, frame: &mut Frame, area: Rect) {
let rule = if self.mode == Mode::Palette {
self.theme.warning
} else {
self.theme.accent
};
let block = Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(rule));
let inner = block.inner(area);
frame.render_widget(block, area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(inner);
let marker = Paragraph::new(Span::styled(
"❯",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
frame.render_widget(marker, cols[0]);
frame.render_widget(self.prompt.widget(), cols[1]);
}
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(22)])
.split(area);
let busy_hint = format!(
" {} running… · Ctrl-C to interrupt",
crate::tui::render::spinner::frame(self.spin)
);
let hint: &str = match self.mode {
Mode::Login => " paste your ZAKURO_API_KEY · Enter submit · Ctrl-C cancel",
Mode::Palette => " ↑↓ select · Tab complete · Enter run · Esc cancel",
Mode::Busy => &busy_hint,
Mode::Normal => " / palette · ↑↓ history · PgUp/PgDn scroll · Ctrl-C quit",
};
frame.render_widget(
Paragraph::new(Span::styled(
hint.to_string(),
Style::default().fg(self.theme.muted),
)),
cols[0],
);
let dot_color = match self.mode {
Mode::Login => self.theme.warning,
Mode::Normal => self.theme.success,
Mode::Palette => self.theme.warning,
Mode::Busy => self.theme.accent,
};
let net = match self.net_mode {
NetworkMode::Local => self.theme.muted,
NetworkMode::P2p => self.theme.accent,
};
let right = Line::from(vec![
Span::styled("● ", Style::default().fg(dot_color)),
Span::styled(
format!("{} ", self.net_mode.label()),
Style::default().fg(net),
),
]);
frame.render_widget(Paragraph::new(right).alignment(Alignment::Right), cols[1]);
}
}
impl Default for ShellApp {
fn default() -> Self {
ShellApp::new()
}
}
fn cleanup_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
pub fn run_shell() -> io::Result<()> {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
cleanup_terminal();
default_panic(info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = ShellApp::new();
while app.running {
app.poll_command();
terminal.draw(|f| app.draw(f))?;
if event::poll(Duration::from_millis(80))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
app.handle_key(key);
}
}
}
}
cleanup_terminal();
terminal.show_cursor()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
use ratatui::backend::TestBackend;
use std::cell::Cell;
use std::rc::Rc;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn typed(app: &mut ShellApp, s: &str) {
for c in s.chars() {
app.handle_key(key(c));
}
}
#[test]
fn help_command_emits_output() {
let mut app = ShellApp::with_authenticated(true);
let before = app.scrollback.len();
typed(&mut app, "help");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.scrollback.len() > before);
assert!(app.prompt.is_empty());
}
#[test]
fn slash_opens_palette_and_filters() {
let mut app = ShellApp::with_authenticated(true);
app.handle_key(key('/'));
assert_eq!(app.mode, Mode::Palette);
typed(&mut app, "the");
assert_eq!(app.palette.selected_name(), Some("theme"));
}
#[test]
fn theme_switch_applies() {
let mut app = ShellApp::with_authenticated(true);
assert_eq!(app.theme.name, "dark");
typed(&mut app, "/theme light");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.theme.name, "light");
}
#[test]
fn ctrl_c_quits() {
let mut app = ShellApp::with_authenticated(true);
assert!(app.running);
app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(!app.running);
}
#[test]
fn banner_places_info_beside_logo() {
let mut app = ShellApp::with_authenticated(true);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| app.draw(f)).unwrap();
let buf = terminal.backend().buffer().clone();
let row_of =
|y: u16| -> String { (0..60).map(|x| buf[(x, y)].symbol()).collect::<String>() };
assert!(row_of(1).contains("┌──┴───┐") && row_of(1).contains("zc shell v"));
assert!(row_of(2).contains("├─○") && row_of(2).contains("Zakuro compute broker"));
}
#[test]
fn draw_renders_without_panic() {
let mut app = ShellApp::with_authenticated(true);
typed(&mut app, "help");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal.draw(|f| app.draw(f)).unwrap();
app.handle_key(key('/'));
terminal.draw(|f| app.draw(f)).unwrap();
}
#[test]
fn starts_in_local_mode_without_auth() {
let app = ShellApp::with_authenticated(false);
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.net_mode, NetworkMode::Local);
}
#[test]
fn switching_to_p2p_prompts_and_keeps_asking() {
let saved = std::env::var("ZAKURO_API_KEY").ok();
let mut app = ShellApp::with_authenticated(false);
app.execute("/mode p2p");
assert_eq!(app.mode, Mode::Login);
typed(&mut app, "badkey");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Login);
assert!(!app.authenticated);
assert_eq!(app.net_mode, NetworkMode::Local);
typed(&mut app, "zk_alice_secret");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.authenticated);
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.net_mode, NetworkMode::P2p);
assert_eq!(
std::env::var("ZAKURO_API_KEY").as_deref(),
Ok("zk_alice_secret")
);
match saved {
Some(v) => std::env::set_var("ZAKURO_API_KEY", v),
None => std::env::remove_var("ZAKURO_API_KEY"),
}
}
#[derive(Clone, Default)]
struct RecordingMesh {
ensures: Rc<Cell<u32>>,
disconnects: Rc<Cell<u32>>,
}
impl MeshControl for RecordingMesh {
fn ensure(&self) -> Result<crate::vpn::MeshAccess, crate::vpn::NetError> {
self.ensures.set(self.ensures.get() + 1);
Ok(crate::vpn::MeshAccess::Host)
}
fn disconnect(&self) -> Result<(), crate::vpn::NetError> {
self.disconnects.set(self.disconnects.get() + 1);
Ok(())
}
}
fn app_with_recording_mesh(authed: bool) -> (ShellApp, RecordingMesh) {
let mesh = RecordingMesh::default();
(ShellApp::with_mesh(authed, Box::new(mesh.clone())), mesh)
}
#[test]
fn mode_local_needs_no_auth() {
let (mut app, mesh) = app_with_recording_mesh(false);
app.execute("/mode p2p"); app.cancel_login();
assert_eq!(app.net_mode, NetworkMode::Local);
assert_eq!(mesh.ensures.get(), 0, "no key: p2p must not touch the mesh");
app.execute("/mode local");
assert_eq!(app.net_mode, NetworkMode::Local);
assert_eq!(app.mode, Mode::Normal);
assert_eq!(mesh.disconnects.get(), 1);
}
#[test]
fn mode_p2p_when_signed_in_goes_through_the_injected_mesh() {
let (mut app, mesh) = app_with_recording_mesh(true);
app.execute("/mode p2p");
assert_eq!(mesh.ensures.get(), 1);
assert_eq!(app.net_mode, NetworkMode::P2p);
}
#[test]
fn sanitize_strips_ansi_tabs_and_controls() {
let s = sanitize_stream_line("\x1b[32mok\x1b[0m\tcol2\r");
assert!(!s.contains('\x1b'));
assert!(!s.contains('\t'));
assert!(!s.contains('\r'));
assert!(s.starts_with("ok"));
assert!(s.contains("col2"));
assert_eq!(sanitize_stream_line("zk0 healthy"), "zk0 healthy");
}
#[test]
fn user_id_parses_from_key() {
assert_eq!(
user_id_from_key("zk_alice_abc123").as_deref(),
Some("alice")
);
assert_eq!(user_id_from_key("nope"), None);
}
#[test]
fn attach_is_blocked_not_spawned() {
let mut app = ShellApp::with_authenticated(true);
typed(&mut app, "attach zc://node");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.cmd_rx.is_none());
}
#[test]
fn unknown_command_errors_without_spawn() {
let mut app = ShellApp::with_authenticated(true);
let before = app.scrollback.len();
typed(&mut app, "definitelynotacommand");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.cmd_rx.is_none());
assert!(app.scrollback.len() > before);
}
}