on main help sign in
feat: now fixed
commitd710d39c279655b55446e3ca5263cf2647a974fd authorrobcholz <84130577+robcholz@users.noreply.github.com> dateFri 6 Feb 2026, 15:00 (8 months ago) parent8597fb3
3 files changed+345−224
docs/tui.md+7−11 src/bin/vibebox-tui.rs+38−6 src/tui.rs+300−207
docs/tui.md+7−11
@@ -4,6 +4,7 @@ The end-user ui for the vibebox
44
55## TUI header
66
7+- One time, not fixed
78- This is a welcome header.
89- Shows text: "Welcome to Vibebox vX.X.XX"
910- Shows the ASCII banner
@@ -12,17 +13,12 @@ The end-user ui for the vibebox
1213 - Shows current vm version and max memory, cpu cores
1314- The position is flex, so this will move with the VM terminal history.
1415
15−## Terminal Area
16+## Vibebox Commands
1617
17−- Shows all the VM terminal history
18+- One time, not fixed
19+- Prints all commands.
20+- Each command includes a description.
1821
19−## Vibebox input area
22+## Normal terminal
2023
21−- A text input area, user can input text in it, it can vertically expand depending on the text length, by default it
22− is a line high.
23−- it should be able to switch to auto-completion mode, which will display a list of available commands. When in this mode, the bottom
24− status bar will disappear. The auto completions are displayed right below the text input area.
25−
26−## Bottom status bar
27−
28−- Display texts in gray, a line high. on the left it shows `:help` for help.
24+- Then the tui should end.
src/bin/vibebox-tui.rs+38−6
@@ -1,4 +1,9 @@
1−use std::{env, ffi::OsString, path::PathBuf};
1+use std::{
2+ env,
3+ ffi::OsString,
4+ io::{self, Read, Write},
5+ path::PathBuf,
6+};
27
38use color_eyre::Result;
49use lexopt::prelude::*;
@@ -33,8 +38,7 @@ enum CliError {
3338 Io(#[from] std::io::Error),
3439}
3540
36−#[tokio::main]
37−async fn main() -> Result<()> {
41+fn main() -> Result<()> {
3842 color_eyre::install()?;
3943
4044 let command = parse_args(env::args_os())?;
@@ -52,15 +56,43 @@ async fn main() -> Result<()> {
5256 cpu_cores: config.cpu_cores,
5357 };
5458 let mut app = AppState::new(config.cwd, vm_info);
55− app.push_history("VM output will appear here.");
56− app.push_history("TODO: wire VM IO into the TUI event loop.");
57− tui::run_tui(app).await?;
59+ tui::render_tui_once(&mut app)?;
60+ {
61+ let mut stdout = io::stdout().lock();
62+ writeln!(stdout)?;
63+ stdout.flush()?;
64+ }
65+ passthrough_stdio(&mut app)?;
5866 }
5967 }
6068
6169 Ok(())
6270}
6371
72+fn passthrough_stdio(app: &mut AppState) -> io::Result<()> {
73+ let mut stdin = io::stdin().lock();
74+ let mut buf = [0u8; 8192];
75+ let mut line_buf: Vec<u8> = Vec::new();
76+ loop {
77+ let n = stdin.read(&mut buf)?;
78+ if n == 0 {
79+ break;
80+ }
81+ for &b in &buf[..n] {
82+ line_buf.push(b);
83+ if b == b'\n' {
84+ let line = String::from_utf8_lossy(&line_buf);
85+ let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
86+ if trimmed == ":help" {
87+ let _ = tui::render_commands_component(app);
88+ }
89+ line_buf.clear();
90+ }
91+ }
92+ }
93+ Ok(())
94+}
95+
6496fn print_help() {
6597 println!(
6698 "vibebox-tui\n\nUsage:\n vibebox-tui [options]\n\nOptions:\n --help, -h Show this help\n --version Show version\n --cwd <path> Working directory for the session header\n --vm-version <ver> VM version string for the header\n --max-memory <mb> Max memory in MB (default 2048)\n --cpu-cores <count> CPU core count (default 2)\n"
src/tui.rs+300−207
@@ -1,27 +1,34 @@
11use std::{
2− io::{self, Stdout},
2+ io::{self, Stdout, Write},
33 path::PathBuf,
4− time::Duration,
54};
65
76use color_eyre::Result;
87use crossterm::{
8+ cursor::{MoveTo, Show},
99 event::{
1010 DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode,
1111 KeyEvent, KeyEventKind, KeyModifiers,
1212 },
13− execute,
14− terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
13+ execute, queue,
14+ style::{
15+ Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
16+ SetForegroundColor,
17+ },
18+ terminal::{
19+ Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
20+ enable_raw_mode,
21+ },
1522};
1623use futures::StreamExt;
1724use ratatui::{
18− backend::CrosstermBackend, buffer::Buffer,
25+ Frame, Terminal,
26+ backend::CrosstermBackend,
27+ buffer::Buffer,
1928 layout::{Constraint, Direction, Layout, Rect},
2029 style::{Color, Style},
2130 text::{Line, Span, Text},
22− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap},
23− Frame,
24− Terminal,
31+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget},
2532};
2633use tui_textarea::{Input, Key, TextArea};
2734use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
@@ -36,10 +43,6 @@ const ASCII_BANNER: [&str; 7] = [
3643 "",
3744];
3845
39−const STATUS_BAR_HEIGHT: u16 = 1;
40−const COMPLETIONS_MAX_HEIGHT: u16 = 6;
41−const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
42−
4346#[derive(Debug, Clone)]
4447pub struct VmInfo {
4548 pub version: String,
@@ -53,11 +56,9 @@ pub struct AppState {
5356 pub vm_info: VmInfo,
5457 pub history: Vec<String>,
5558 pub input: TextArea<'static>,
56− pub completions: CompletionState,
59+ pub commands: VibeboxCommands,
5760 pub should_quit: bool,
5861 key_input_mode: KeyInputMode,
59− tick: u64,
60− spinner: usize,
6162 page_scroll: usize,
6263 input_view_width: u16,
6364}
@@ -65,17 +66,18 @@ pub struct AppState {
6566impl AppState {
6667 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
6768 let input = Self::default_input();
69+ let mut commands = VibeboxCommands::default();
70+ commands.add_command(":new", "Create a new session.");
71+ commands.add_command(":exit", "Exit Vibebox.");
6872
6973 Self {
7074 cwd,
7175 vm_info,
7276 history: Vec::new(),
7377 input,
74− completions: CompletionState::default(),
78+ commands,
7579 should_quit: false,
7680 key_input_mode: KeyInputMode::Unknown,
77− tick: 0,
78− spinner: 0,
7981 page_scroll: 0,
8082 input_view_width: 0,
8183 }
@@ -171,27 +173,26 @@ impl AppState {
171173 }
172174 }
173175
174− pub fn activate_completions(&mut self, items: Vec<String>) {
175− self.completions.set_items(items);
176− self.completions.activate();
176+ pub fn activate_commands(&mut self) {
177+ self.commands.activate();
177178 }
178179
179− pub fn deactivate_completions(&mut self) {
180− self.completions.deactivate();
180+ pub fn deactivate_commands(&mut self) {
181+ self.commands.deactivate();
181182 }
182183
183− pub fn toggle_completions(&mut self, items: Vec<String>) {
184− if self.completions.active {
185− self.deactivate_completions();
184+ pub fn toggle_commands(&mut self) {
185+ if self.commands.active {
186+ self.deactivate_commands();
186187 } else {
187− self.activate_completions(items);
188+ self.activate_commands();
188189 }
189190 }
190191}
191192
192−#[derive(Debug, Default, Clone)]
193−pub struct CompletionState {
194− items: Vec<String>,
193+#[derive(Debug, Clone)]
194+pub struct VibeboxCommands {
195+ items: Vec<VibeboxCommand>,
195196 selected: usize,
196197 active: bool,
197198}
@@ -203,12 +204,19 @@ enum KeyInputMode {
203204 Release,
204205}
205206
206−impl CompletionState {
207+impl VibeboxCommands {
207208 pub fn set_items(&mut self, items: Vec<String>) {
208− self.items = items;
209+ self.items = items.into_iter().map(VibeboxCommand::new).collect();
209210 self.selected = 0;
210211 }
211212
213+ pub fn add_command(&mut self, name: impl Into<String>, description: impl Into<String>) {
214+ self.items.push(VibeboxCommand {
215+ name: name.into(),
216+ description: description.into(),
217+ });
218+ }
219+
212220 pub fn activate(&mut self) {
213221 self.active = !self.items.is_empty();
214222 self.selected = 0;
@@ -238,13 +246,13 @@ impl CompletionState {
238246
239247 pub fn current(&self) -> Option<&str> {
240248 if self.active {
241− self.items.get(self.selected).map(|s| s.as_str())
249+ self.items.get(self.selected).map(|cmd| cmd.name.as_str())
242250 } else {
243251 None
244252 }
245253 }
246254
247− pub fn items(&self) -> &[String] {
255+ pub fn items(&self) -> &[VibeboxCommand] {
248256 &self.items
249257 }
250258
@@ -257,6 +265,32 @@ impl CompletionState {
257265 }
258266}
259267
268+impl Default for VibeboxCommands {
269+ fn default() -> Self {
270+ Self {
271+ items: vec![VibeboxCommand {
272+ name: ":help".to_string(),
273+ description: "Show Vibebox commands.".to_string(),
274+ }],
275+ selected: 0,
276+ active: false,
277+ }
278+ }
279+}
280+
281+#[derive(Debug, Default, Clone)]
282+pub struct VibeboxCommand {
283+ pub name: String,
284+ pub description: String,
285+}
286+
287+impl VibeboxCommand {
288+ pub fn new(name: String) -> Self {
289+ let description = command_description(&name).to_string();
290+ Self { name, description }
291+ }
292+}
293+
260294#[allow(dead_code)]
261295#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262296pub struct LayoutAreas {
@@ -278,79 +312,63 @@ struct PageLayout {
278312}
279313
280314#[allow(dead_code)]
281−pub fn compute_layout(
282− area: Rect,
283− input_height: u16,
284− completion_items: usize,
285− completion_active: bool,
286−) -> LayoutAreas {
315+pub fn compute_layout(area: Rect, input_height: u16, completion_items: usize) -> LayoutAreas {
287316 let header_height = header_height().min(area.height);
288317 let mut remaining = area.height.saturating_sub(header_height);
289318
290319 let input_height = input_height.max(1).min(remaining);
291320 remaining = remaining.saturating_sub(input_height);
292321
293− let (completion_height, status_height) = if completion_active {
294− let desired = (completion_items as u16).min(COMPLETIONS_MAX_HEIGHT);
295− let height = desired.min(remaining);
296− (height, 0)
322+ let completion_height = if completion_items == 0 {
323+ 0
297324 } else {
298− let height = STATUS_BAR_HEIGHT.min(remaining);
299− (0, height)
325+ let desired = (completion_items as u16).saturating_add(2);
326+ desired
300327 };
328+ let completion_height = completion_height.min(remaining);
301329
302− remaining = remaining.saturating_sub(completion_height + status_height);
303−
304− let terminal_height = remaining;
330+ let terminal_height = 0;
305331
306− let chunks = Layout::default()
307− .direction(Direction::Vertical)
308− .constraints([
309− Constraint::Length(header_height),
310− Constraint::Length(terminal_height),
311− Constraint::Length(input_height),
312− Constraint::Length(completion_height),
313− Constraint::Length(status_height),
314− ])
315− .split(area);
332+ let mut y = 0u16;
333+ let header = Rect::new(area.x, area.y + y, area.width, header_height);
334+ y = y.saturating_add(header_height);
335+ let terminal = Rect::new(area.x, area.y + y, area.width, terminal_height);
336+ let input = Rect::new(area.x, area.y + y, area.width, input_height);
337+ y = y.saturating_add(input_height);
338+ let completions = Rect::new(area.x, area.y + y, area.width, completion_height);
339+ let status = Rect::new(area.x, area.y + y, area.width, 0);
316340
317341 LayoutAreas {
318− header: chunks[0],
319− terminal: chunks[1],
320− input: chunks[2],
321− completions: chunks[3],
322− status: chunks[4],
342+ header,
343+ terminal,
344+ input,
345+ completions,
346+ status,
323347 }
324348}
325349
326350fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
327351 let header_height = header_height();
328− let terminal_height = terminal_height(app, width);
329− let input_height = app.input_height_for_width(width);
330− let (completion_height, status_height) = if app.completions.is_active() {
331− let desired = (app.completions.items().len() as u16).min(COMPLETIONS_MAX_HEIGHT);
332− (desired.saturating_add(2), 0)
352+ let input_height = 0;
353+ let completion_items = app.commands.items().len();
354+ let completion_height = if completion_items == 0 {
355+ 0
333356 } else {
334− (0, STATUS_BAR_HEIGHT)
357+ (completion_items as u16).saturating_add(2)
335358 };
336−
337359 let total_height = header_height
338− .saturating_add(terminal_height)
339360 .saturating_add(input_height)
340361 .saturating_add(completion_height)
341− .saturating_add(status_height)
342362 .max(1);
343363
344364 let mut y = 0u16;
345365 let header = Rect::new(0, y, width, header_height);
346366 y = y.saturating_add(header_height);
347− let terminal = Rect::new(0, y, width, terminal_height);
348− y = y.saturating_add(terminal_height);
367+ let terminal = Rect::new(0, y, width, 0);
349368 let input = Rect::new(0, y, width, input_height);
350369 y = y.saturating_add(input_height);
351370 let completions = Rect::new(0, y, width, completion_height);
352− y = y.saturating_add(completion_height);
353− let status = Rect::new(0, y, width, status_height);
371+ let status = Rect::new(0, y, width, 0);
354372
355373 PageLayout {
356374 header,
@@ -362,23 +380,6 @@ fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
362380 }
363381}
364382
365−fn terminal_height(app: &AppState, width: u16) -> u16 {
366− if width == 0 {
367− return 0;
368− }
369− let lines: Vec<Line> = app
370− .history
371− .iter()
372− .map(|line| Line::from(line.as_str()))
373− .collect();
374− let block = Block::default().borders(Borders::ALL).title("Terminal");
375− let paragraph = Paragraph::new(Text::from_iter(lines))
376− .block(block)
377− .wrap(Wrap { trim: true });
378− let count = paragraph.line_count(width).max(1);
379− (count.min(u16::MAX as usize)) as u16
380−}
381−
382383fn header_height() -> u16 {
383384 let banner_height = ASCII_BANNER.len() as u16;
384385 let welcome_height = 1;
@@ -389,21 +390,12 @@ fn header_height() -> u16 {
389390pub async fn run_tui(mut app: AppState) -> Result<()> {
390391 let mut terminal = TerminalGuard::init()?;
391392 let mut events = EventStream::new();
392− let mut tick = tokio::time::interval(Duration::from_millis(250));
393393
394394 loop {
395395 terminal.draw(|frame| render(frame, &mut app))?;
396396
397− tokio::select! {
398− _ = tick.tick() => {
399− app.tick = app.tick.wrapping_add(1);
400− app.spinner = (app.spinner + 1) % SPINNER_FRAMES.len();
401− },
402− event = events.next() => {
403− if let Some(event) = event {
404− handle_event(event?, &mut app);
405− }
406− }
397+ if let Some(event) = events.next().await {
398+ handle_event(event?, &mut app);
407399 }
408400
409401 if app.should_quit {
@@ -414,6 +406,161 @@ pub async fn run_tui(mut app: AppState) -> Result<()> {
414406 Ok(())
415407}
416408
409+pub fn render_tui_once(app: &mut AppState) -> Result<()> {
410+ let (width, _) = crossterm::terminal::size()?;
411+ if width == 0 {
412+ return Ok(());
413+ }
414+
415+ let buffer = render_static_buffer(app, width);
416+ let mut stdout = io::stdout();
417+ execute!(stdout, Clear(ClearType::All), MoveTo(0, 0), Show)?;
418+ write_buffer_with_style(&buffer, &mut stdout)?;
419+ stdout.flush()?;
420+ Ok(())
421+}
422+
423+pub fn render_commands_component(app: &mut AppState) -> Result<()> {
424+ let (width, _) = crossterm::terminal::size()?;
425+ if width == 0 {
426+ return Ok(());
427+ }
428+
429+ let command_count = app.commands.items().len() as u16;
430+ let height = if command_count == 0 {
431+ 0
432+ } else {
433+ command_count.saturating_add(2)
434+ };
435+ if height == 0 {
436+ return Ok(());
437+ }
438+
439+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, height));
440+ let area = Rect::new(0, 0, width, height);
441+ render_completions(&mut buffer, area, app);
442+
443+ let mut stdout = io::stdout();
444+ write_buffer_with_style(&buffer, &mut stdout)?;
445+ stdout.flush()?;
446+ Ok(())
447+}
448+
449+fn render_static_buffer(app: &mut AppState, width: u16) -> Buffer {
450+ let layout = compute_page_layout(app, width);
451+ let content_height = layout.total_height.max(1);
452+ let mut buffer = Buffer::empty(Rect::new(0, 0, width, content_height));
453+ render_header(&mut buffer, layout.header, app);
454+ render_completions(&mut buffer, layout.completions, app);
455+ buffer
456+}
457+
458+fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<()> {
459+ let area = buffer.area;
460+ let mut current_fg: Option<CrosstermColor> = None;
461+ let mut current_bg: Option<CrosstermColor> = None;
462+ let mut current_modifier: Option<ratatui::style::Modifier> = None;
463+
464+ for y in 0..area.height {
465+ for x in 0..area.width {
466+ let cell = &buffer[(x, y)];
467+ if cell.skip {
468+ continue;
469+ }
470+
471+ let fg = map_color(cell.fg);
472+ let bg = map_color(cell.bg);
473+ let modifier = cell.modifier;
474+ if current_fg != Some(fg)
475+ || current_bg != Some(bg)
476+ || current_modifier != Some(modifier)
477+ {
478+ queue!(out, SetAttribute(Attribute::Reset))?;
479+ queue!(out, SetForegroundColor(fg), SetBackgroundColor(bg))?;
480+ queue_modifier(out, modifier)?;
481+ current_fg = Some(fg);
482+ current_bg = Some(bg);
483+ current_modifier = Some(modifier);
484+ }
485+
486+ let symbol = cell.symbol();
487+ if symbol.is_empty() {
488+ queue!(out, Print(" "))?;
489+ } else {
490+ queue!(out, Print(symbol))?;
491+ }
492+ }
493+ queue!(
494+ out,
495+ SetAttribute(Attribute::Reset),
496+ SetForegroundColor(CrosstermColor::Reset),
497+ SetBackgroundColor(CrosstermColor::Reset),
498+ Print("\n")
499+ )?;
500+ current_fg = None;
501+ current_bg = None;
502+ current_modifier = None;
503+ }
504+
505+ Ok(())
506+}
507+
508+fn map_color(color: ratatui::style::Color) -> CrosstermColor {
509+ match color {
510+ ratatui::style::Color::Reset => CrosstermColor::Reset,
511+ ratatui::style::Color::Black => CrosstermColor::Black,
512+ ratatui::style::Color::Red => CrosstermColor::DarkRed,
513+ ratatui::style::Color::Green => CrosstermColor::DarkGreen,
514+ ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
515+ ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
516+ ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
517+ ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
518+ ratatui::style::Color::Gray => CrosstermColor::Grey,
519+ ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
520+ ratatui::style::Color::LightRed => CrosstermColor::Red,
521+ ratatui::style::Color::LightGreen => CrosstermColor::Green,
522+ ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
523+ ratatui::style::Color::LightBlue => CrosstermColor::Blue,
524+ ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
525+ ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
526+ ratatui::style::Color::White => CrosstermColor::White,
527+ ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
528+ ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
529+ }
530+}
531+
532+fn queue_modifier(out: &mut impl Write, modifier: ratatui::style::Modifier) -> io::Result<()> {
533+ use ratatui::style::Modifier;
534+ if modifier.contains(Modifier::BOLD) {
535+ queue!(out, SetAttribute(Attribute::Bold))?;
536+ }
537+ if modifier.contains(Modifier::DIM) {
538+ queue!(out, SetAttribute(Attribute::Dim))?;
539+ }
540+ if modifier.contains(Modifier::ITALIC) {
541+ queue!(out, SetAttribute(Attribute::Italic))?;
542+ }
543+ if modifier.contains(Modifier::UNDERLINED) {
544+ queue!(out, SetAttribute(Attribute::Underlined))?;
545+ }
546+ if modifier.contains(Modifier::SLOW_BLINK) {
547+ queue!(out, SetAttribute(Attribute::SlowBlink))?;
548+ }
549+ if modifier.contains(Modifier::RAPID_BLINK) {
550+ queue!(out, SetAttribute(Attribute::RapidBlink))?;
551+ }
552+ if modifier.contains(Modifier::REVERSED) {
553+ queue!(out, SetAttribute(Attribute::Reverse))?;
554+ }
555+ if modifier.contains(Modifier::HIDDEN) {
556+ queue!(out, SetAttribute(Attribute::Hidden))?;
557+ }
558+ if modifier.contains(Modifier::CROSSED_OUT) {
559+ queue!(out, SetAttribute(Attribute::CrossedOut))?;
560+ }
561+ Ok(())
562+}
563+
417564fn handle_event(event: CrosstermEvent, app: &mut AppState) {
418565 match event {
419566 CrosstermEvent::Key(key) => handle_key_event(key, app),
@@ -432,16 +579,16 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
432579 return;
433580 }
434581
435− if app.completions.is_active() {
582+ if app.commands.is_active() {
436583 match key.code {
437− KeyCode::Esc => app.deactivate_completions(),
438− KeyCode::Up => app.completions.previous(),
439− KeyCode::Down => app.completions.next(),
584+ KeyCode::Esc => app.deactivate_commands(),
585+ KeyCode::Up => app.commands.previous(),
586+ KeyCode::Down => app.commands.next(),
440587 KeyCode::Enter => {
441− if let Some(selection) = app.completions.current() {
588+ if let Some(selection) = app.commands.current() {
442589 app.input.insert_str(selection);
443590 }
444− app.deactivate_completions();
591+ app.deactivate_commands();
445592 }
446593 _ => {}
447594 }
@@ -464,7 +611,7 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
464611 app.page_scroll = usize::MAX;
465612 }
466613 }
467− KeyCode::Tab => app.toggle_completions(default_completions()),
614+ KeyCode::Tab => app.toggle_commands(),
468615 KeyCode::Char(c)
469616 if !key
470617 .modifiers
@@ -547,8 +694,13 @@ fn input_from_key_event(key: KeyEvent) -> Input {
547694 }
548695}
549696
550−fn default_completions() -> Vec<String> {
551− vec![":help".to_string(), ":new".to_string(), ":exit".to_string()]
697+fn command_description(command: &str) -> &'static str {
698+ match command {
699+ ":help" => "Show Vibebox commands.",
700+ ":new" => "Create a new session.",
701+ ":exit" => "Exit Vibebox.",
702+ _ => "",
703+ }
552704}
553705
554706fn render(frame: &mut Frame<'_>, app: &mut AppState) {
@@ -563,14 +715,7 @@ fn render(frame: &mut Frame<'_>, app: &mut AppState) {
563715
564716 let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
565717 render_header(&mut buffer, layout.header, app);
566− render_terminal(&mut buffer, layout.terminal, app);
567− let cursor_pos = render_input(&mut buffer, layout.input, app);
568−
569− if app.completions.is_active() {
570− render_completions(&mut buffer, layout.completions, app);
571− } else {
572− render_status(&mut buffer, layout.status, app);
573− }
718+ render_completions(&mut buffer, layout.completions, app);
574719
575720 let max_scroll = content_height.saturating_sub(viewport.height);
576721 app.page_scroll = app.page_scroll.min(max_scroll as usize);
@@ -586,12 +731,6 @@ fn render(frame: &mut Frame<'_>, app: &mut AppState) {
586731 view[(x, y)] = buffer[(x, src_y)].clone();
587732 }
588733 }
589−
590− if let Some((x, y)) = cursor_pos {
591− if y >= scroll && y < scroll.saturating_add(viewport.height) {
592− frame.set_cursor_position((x, y - scroll));
593− }
594− }
595734}
596735
597736fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
@@ -645,80 +784,34 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
645784 .render(header_chunks[2], buffer);
646785}
647786
648−fn render_terminal(buffer: &mut Buffer, area: Rect, app: &AppState) {
649− if area.height == 0 {
650− return;
651− }
652− let lines = app.history.iter().map(|line| Line::from(line.as_str()));
653− let block = Block::default().borders(Borders::ALL).title("Terminal");
654− let paragraph = Paragraph::new(Text::from_iter(lines))
655− .block(block)
656− .wrap(Wrap { trim: true });
657−
658− paragraph.render(area, buffer);
659−}
660−
661−fn render_input(buffer: &mut Buffer, area: Rect, app: &mut AppState) -> Option<(u16, u16)> {
662− if area.height == 0 || area.width == 0 {
663− return None;
664− }
665− app.input.render(area, buffer);
666− let cursor = app.input.cursor();
667− let inner = match app.input.block() {
668− Some(block) => block.inner(area),
669− None => area,
670− };
671− let x = inner.x.saturating_add(cursor.1 as u16);
672− let y = inner.y.saturating_add(cursor.0 as u16);
673− if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
674− Some((x, y))
675− } else {
676− None
677− }
678−}
679−
680787fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
681788 if area.height == 0 {
682789 return;
683790 }
684791
685792 let items: Vec<ListItem<'_>> = app
686− .completions
793+ .commands
687794 .items()
688795 .iter()
689− .map(|item| ListItem::new(Line::from(item.as_str())))
796+ .map(|cmd| ListItem::new(Line::from(format!("{} {}", cmd.name, cmd.description))))
690797 .collect();
691798
692799 let list = List::new(items)
693− .block(Block::default().borders(Borders::ALL).title("Completions"))
800+ .block(
801+ Block::default()
802+ .borders(Borders::ALL)
803+ .title("Vibebox Commands"),
804+ )
694805 .highlight_style(Style::default().fg(Color::Yellow));
695806
696807 let mut state = ListState::default();
697− if app.completions.is_active() && !app.completions.items().is_empty() {
698− state.select(Some(app.completions.selected()));
808+ if app.commands.is_active() && !app.commands.items().is_empty() {
809+ state.select(Some(app.commands.selected()));
699810 }
700811
701812 StatefulWidget::render(list, area, buffer, &mut state);
702813}
703814
704−fn render_status(buffer: &mut Buffer, area: Rect, app: &AppState) {
705− if area.height == 0 {
706− return;
707− }
708−
709− let spinner = SPINNER_FRAMES[app.spinner % SPINNER_FRAMES.len()];
710− let status = Paragraph::new(Line::from(vec![
711− Span::styled(":help", Style::default().fg(Color::DarkGray)),
712− Span::raw(" "),
713− Span::styled(
714− format!("tick {} {}", app.tick, spinner),
715− Style::default().fg(Color::DarkGray),
716− ),
717− ]));
718−
719− status.render(area, buffer);
720−}
721−
722815struct TerminalGuard {
723816 terminal: Terminal<CrosstermBackend<Stdout>>,
724817}
@@ -755,29 +848,29 @@ mod tests {
755848 use super::*;
756849
757850 #[test]
758− fn layout_without_completions_reserves_status_bar() {
851+ fn layout_without_completions_hides_status_bar() {
759852 let area = Rect::new(0, 0, 80, 30);
760− let layout = compute_layout(area, 3, 0, false);
853+ let layout = compute_layout(area, 3, 0);
761854
762855 assert_eq!(layout.header.height, header_height());
763− assert_eq!(layout.status.height, STATUS_BAR_HEIGHT);
856+ assert_eq!(layout.status.height, 0);
764857 assert_eq!(layout.completions.height, 0);
765− assert!(layout.terminal.height > 0);
858+ assert_eq!(layout.terminal.height, 0);
766859 }
767860
768861 #[test]
769862 fn layout_with_completions_hides_status_bar() {
770863 let area = Rect::new(0, 0, 80, 30);
771− let layout = compute_layout(area, 4, 3, true);
864+ let layout = compute_layout(area, 4, 3);
772865
773866 assert_eq!(layout.status.height, 0);
774− assert_eq!(layout.completions.height, 3);
867+ assert_eq!(layout.completions.height, 5);
775868 }
776869
777870 #[test]
778871 fn layout_clamps_when_space_is_tight() {
779872 let area = Rect::new(0, 0, 80, header_height() + 1);
780− let layout = compute_layout(area, 3, 10, true);
873+ let layout = compute_layout(area, 3, 10);
781874
782875 assert_eq!(layout.header.height, header_height());
783876 assert_eq!(layout.input.height, 1);
@@ -785,31 +878,31 @@ mod tests {
785878 }
786879
787880 #[test]
788− fn completion_state_wraps_navigation() {
789− let mut completions = CompletionState::default();
790− completions.set_items(vec!["a".into(), "b".into(), "c".into()]);
791− completions.activate();
881+ fn commands_wrap_navigation() {
882+ let mut commands = VibeboxCommands::default();
883+ commands.set_items(vec![":new".into(), ":exit".into(), ":help".into()]);
884+ commands.activate();
792885
793− assert_eq!(completions.current(), Some("a"));
886+ assert_eq!(commands.current(), Some(":new"));
794887
795− completions.next();
796− assert_eq!(completions.current(), Some("b"));
888+ commands.next();
889+ assert_eq!(commands.current(), Some(":exit"));
797890
798− completions.next();
799− completions.next();
800− assert_eq!(completions.current(), Some("a"));
891+ commands.next();
892+ commands.next();
893+ assert_eq!(commands.current(), Some(":new"));
801894
802− completions.previous();
803− assert_eq!(completions.current(), Some("c"));
895+ commands.previous();
896+ assert_eq!(commands.current(), Some(":help"));
804897 }
805898
806899 #[test]
807− fn completion_state_is_inactive_when_empty() {
808− let mut completions = CompletionState::default();
809− completions.activate();
900+ fn commands_inactive_when_empty() {
901+ let mut commands = VibeboxCommands::default();
902+ commands.activate();
810903
811− assert!(!completions.is_active());
812− assert_eq!(completions.current(), None);
904+ assert!(!commands.is_active());
905+ assert_eq!(commands.current(), None);
813906 }
814907
815908 #[test]