mirror of
https://github.com/robcholz/vibebox.git
synced 2026-04-01 00:10:15 +02:00
feat: added send to terminal
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
136
install.sh
Normal file
136
install.sh
Normal file
@@ -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
|
||||
108
src/tui.rs
108
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<String>) {
|
||||
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<String> {
|
||||
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user