From 8764a4c9977cfd899e16398ed9cb9fbd7336229b Mon Sep 17 00:00:00 2001 From: robcholz <84130577+robcholz@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:14:03 -0500 Subject: [PATCH] feat: added send to terminal --- Cargo.lock | 7 +-- Cargo.toml | 5 +- docs/tasks.md | 19 ++++--- install.sh | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/tui.rs | 108 +++++++++++++++++++++++++++++++++++---- 5 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 install.sh diff --git a/Cargo.lock b/Cargo.lock index 6f5647f..2e7d84f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1105,12 +1105,12 @@ dependencies = [ [[package]] name = "tui-textarea" -version = "0.4.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ "ratatui", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] @@ -1188,6 +1188,7 @@ dependencies = [ "tokio", "toml", "tui-textarea", + "unicode-width 0.2.0", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index efead0c..3c0fb6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ uuid = { version = "1", features = ["v7", "serde"] } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["event-stream"] } futures = "0.3.31" -ratatui = "0.29.0" +ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } tokio = { version = "1.40.0", features = ["full"] } -tui-textarea = { version = "0.4", default-features = false, features = ["ratatui"] } +tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] } +unicode-width = "0.2" diff --git a/docs/tasks.md b/docs/tasks.md index 2c99afe..7b69425 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -3,11 +3,15 @@ ## SessionManager 1. [x] Confirm requirements and scope from `implementations.md`. -2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries). -3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`). -4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index persistence. +2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, + refcount handling, cleanup orphaned index entries). +3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, + `thiserror`). +4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index + persistence. 5. [x] Implement `SessionManager` and supporting types with `Result`-based errors, filesystem IO, and atomic writes. -6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir). +6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on + missing instance dir). 7. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`). 8. [x] Refactor for clarity and reliability while keeping tests green. @@ -15,7 +19,8 @@ 1. [x] Review `docs/tui.md` requirements and translate into concrete UI sections and state model. 2. [x] Add required dependencies for ratatui/crossterm/tokio/color-eyre/futures and pick a text input widget crate. -3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state transitions, and CLI argument parsing. +3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state + transitions, and CLI argument parsing. 4. [x] Implement TUI state model (header info, terminal history, input area, completion list, status bar visibility). 5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar. 6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio. @@ -24,7 +29,9 @@ ## TUI - +1. [ ] Fix the terminal component height issue. +2. [ ] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The + inputfield it should not be scrollable. ## Integration diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..966dcf2 --- /dev/null +++ b/install.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP=vibebox +REPO_URL="https://github.com/opencode-ai/vibebox" +REQUESTED_VERSION=${VERSION:-} + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +ORANGE='\033[38;2;255;140;0m' +NC='\033[0m' # No Color + +print_message() { + local level=$1 + local message=$2 + local color="" + + case $level in + info) color="${GREEN}" ;; + warning) color="${YELLOW}" ;; + error) color="${RED}" ;; + esac + + echo -e "${color}${message}${NC}" +} + +require_cmd() { + local cmd=$1 + local hint=$2 + + if ! command -v "$cmd" >/dev/null 2>&1; then + print_message error "Missing required command: ${cmd}" + if [[ -n "$hint" ]]; then + print_message info "$hint" + fi + exit 1 + fi +} + +require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry." +require_cmd git "Install git and retry." + +CARGO_HOME=${CARGO_HOME:-$HOME/.cargo} +INSTALL_DIR="$CARGO_HOME/bin" + +installed_version="" +if command -v "$APP" >/dev/null 2>&1; then + installed_version=$("$APP" --version 2>/dev/null | awk '{print $2}' | head -n1 || true) +fi + +if [[ -n "$REQUESTED_VERSION" && "$installed_version" == "$REQUESTED_VERSION" ]]; then + print_message info "Version ${YELLOW}$REQUESTED_VERSION${GREEN} already installed" + exit 0 +fi + +install_args=(install "$APP" --locked --git "$REPO_URL") +if [[ -n "$REQUESTED_VERSION" ]]; then + install_args+=(--tag "v$REQUESTED_VERSION") +fi + +if command -v "$APP" >/dev/null 2>&1; then + install_args+=(--force) +fi + +print_message info "Installing ${ORANGE}${APP}${GREEN}..." +print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..." + +cargo "${install_args[@]}" + +add_to_path() { + local config_file=$1 + local command=$2 + + if [[ -w $config_file ]]; then + echo -e "\n# vibebox" >> "$config_file" + echo "$command" >> "$config_file" + print_message info "Added ${ORANGE}${APP}${GREEN} to \$PATH in $config_file" + else + print_message warning "Manually add the directory to $config_file (or similar):" + print_message info " $command" + fi +} + +XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} +current_shell=$(basename "$SHELL") + +case $current_shell in + fish) + config_files="$HOME/.config/fish/config.fish" + ;; + zsh) + config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv" + ;; + bash) + config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" + ;; + ash) + config_files="$HOME/.ashrc $HOME/.profile /etc/profile" + ;; + sh) + config_files="$HOME/.ashrc $HOME/.profile /etc/profile" + ;; + *) + config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile" + ;; +esac + +config_file="" +for file in $config_files; do + if [[ -f $file ]]; then + config_file=$file + break + fi +done + +if [[ -z $config_file ]]; then + print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}" + exit 1 +fi + +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + case $current_shell in + fish) + add_to_path "$config_file" "fish_add_path $INSTALL_DIR" + ;; + *) + add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH" + ;; + esac +fi + +if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then + echo "$INSTALL_DIR" >> "$GITHUB_PATH" + print_message info "Added $INSTALL_DIR to \$GITHUB_PATH" +fi diff --git a/src/tui.rs b/src/tui.rs index cca1436..24e15c7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -23,6 +23,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, }; use tui_textarea::{Input, Key, TextArea}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; const ASCII_BANNER: [&str; 7] = [ "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░", @@ -57,13 +58,12 @@ pub struct AppState { tick: u64, spinner: usize, terminal_scroll: usize, + input_view_width: u16, } impl AppState { pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self { - let mut input = TextArea::default(); - input.set_cursor_style(Style::default().fg(Color::Yellow)); - input.set_block(Block::default().borders(Borders::ALL).title("Input")); + let input = Self::default_input(); Self { cwd, @@ -76,21 +76,92 @@ impl AppState { tick: 0, spinner: 0, terminal_scroll: 0, + input_view_width: 0, } } - pub fn input_line_count(&self) -> u16 { - self.input.lines().len().max(1) as u16 + fn default_input() -> TextArea<'static> { + let mut input = TextArea::default(); + input.set_cursor_style(Style::default().fg(Color::Yellow)); + input.set_block(Block::default().borders(Borders::ALL).title("Input")); + input } - pub fn input_height(&self) -> u16 { - let mut height = self.input_line_count(); + fn reset_input(&mut self) { + self.input = Self::default_input(); + } + + fn take_input_text(&mut self) -> String { + let text = self.input.lines().join(""); + self.reset_input(); + text + } + + pub fn input_height_for_width(&self, available_width: u16) -> u16 { + let inner_width = self.input_inner_width(available_width).max(1) as usize; + let mut visual_lines = 0usize; + for line in self.input.lines() { + let line_width = UnicodeWidthStr::width(line.as_str()); + let wrapped = if line_width == 0 { + 1 + } else { + (line_width + inner_width - 1) / inner_width + }; + visual_lines += wrapped; + } + + let mut height = (visual_lines.max(1) as u16).max(1); if self.input.block().is_some() { height = height.saturating_add(2); } height.max(1) } + fn input_inner_width(&self, available_width: u16) -> u16 { + if self.input.block().is_some() { + available_width.saturating_sub(2) + } else { + available_width + } + } + + fn insert_char_with_wrap(&mut self, c: char) { + let char_width = UnicodeWidthChar::width(c).unwrap_or(0); + let width = self.input_view_width.max(1) as usize; + let mut should_wrap = false; + if char_width > 0 && width > 0 { + let (row, col) = self.input.cursor(); + if let Some(line) = self.input.lines().get(row) { + if col == line.chars().count() { + let line_width = UnicodeWidthStr::width(line.as_str()); + should_wrap = line_width + char_width > width; + } + } + } + + if should_wrap { + if c == ' ' { + self.input.insert_char(c); + self.input.insert_newline(); + } else { + self.input.insert_newline(); + self.input.insert_char(c); + } + } else { + self.input.insert_char(c); + } + } + + fn insert_str_with_wrap(&mut self, text: &str) { + for c in text.chars() { + match c { + '\n' => self.insert_char_with_wrap(' '), + '\r' => {} + _ => self.insert_char_with_wrap(c), + } + } + } + pub fn push_history(&mut self, line: impl Into) { self.history.push(line.into()); if self.history.len() > 2000 { @@ -281,7 +352,7 @@ fn handle_event(event: CrosstermEvent, app: &mut AppState) { CrosstermEvent::Mouse(event) => handle_mouse_event(event, app), CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {} CrosstermEvent::Paste(text) => { - app.input.insert_str(&text); + app.insert_str_with_wrap(&text); } } } @@ -325,7 +396,21 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) { KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { app.terminal_scroll = app.terminal_scroll.saturating_sub(1); } + KeyCode::Enter => { + let message = app.take_input_text(); + if !message.trim().is_empty() { + app.push_history(format!("> {}", message)); + app.terminal_scroll = 0; + } + } KeyCode::Tab => app.toggle_completions(default_completions()), + KeyCode::Char(c) + if !key + .modifiers + .contains(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + app.insert_char_with_wrap(c); + } _ => { app.input.input(input_from_key_event(key)); } @@ -407,9 +492,10 @@ fn default_completions() -> Vec { fn render(frame: &mut Frame<'_>, app: &mut AppState) { let area = frame.area(); + app.input_view_width = app.input_inner_width(area.width); let layout = compute_layout( area, - app.input_height(), + app.input_height_for_width(area.width), app.completions.items().len(), app.completions.is_active(), ); @@ -491,14 +577,14 @@ fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) { let scroll_top = max_top.saturating_sub(terminal_scroll); let paragraph = Paragraph::new(Text::from_iter(lines)) .block(block) - .wrap(Wrap { trim: false }) + .wrap(Wrap { trim: true }) .scroll((scroll_top.min(u16::MAX as usize) as u16, 0)); frame.render_widget(paragraph, area); } fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { - frame.render_widget(app.input.widget(), area); + frame.render_widget(&app.input, area); if area.height > 0 && area.width > 0 { let cursor = app.input.cursor(); let inner = match app.input.block() {