on main help sign in
feat: added send to terminal
commit8764a4c9977cfd899e16398ed9cb9fbd7336229b authorrobcholz <84130577+robcholz@users.noreply.github.com> dateThu 5 Feb 2026, 23:14 (8 months ago) parent0bd9aea
5 files changed+253−22
Cargo.lock+4−3 Cargo.toml+3−2 docs/tasks.md+13−6 install.sh+136 src/tui.rs+97−11
Cargo.lock+4−3
@@ -1105,12 +1105,12 @@ dependencies = [
11051105
11061106[[package]]
11071107name = "tui-textarea"
1108−version = "0.4.0"
1108+version = "0.7.0"
11091109source = "registry+https://github.com/rust-lang/crates.io-index"
1110−checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e"
1110+checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
11111111dependencies = [
11121112 "ratatui",
1113− "unicode-width 0.1.14",
1113+ "unicode-width 0.2.0",
11141114]
11151115
11161116[[package]]
@@ -1188,6 +1188,7 @@ dependencies = [
11881188 "tokio",
11891189 "toml",
11901190 "tui-textarea",
1191+ "unicode-width 0.2.0",
11911192 "uuid",
11921193]
11931194
Cargo.toml+3−2
@@ -30,6 +30,7 @@ uuid = { version = "1", features = ["v7", "serde"] }
3030color-eyre = "0.6.3"
3131crossterm = { version = "0.28.1", features = ["event-stream"] }
3232futures = "0.3.31"
33−ratatui = "0.29.0"
33+ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
3434tokio = { version = "1.40.0", features = ["full"] }
35−tui-textarea = { version = "0.4", default-features = false, features = ["ratatui"] }
35+tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
36+unicode-width = "0.2"
docs/tasks.md+13−6
@@ -3,11 +3,15 @@
33## SessionManager
44
551. [x] Confirm requirements and scope from `implementations.md`.
6−2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries).
7−3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`).
8−4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index persistence.
6+2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active,
7+ refcount handling, cleanup orphaned index entries).
8+3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`,
9+ `thiserror`).
10+4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index
11+ persistence.
9125. [x] Implement `SessionManager` and supporting types with `Result`-based errors, filesystem IO, and atomic writes.
10−6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir).
13+6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on
14+ missing instance dir).
11157. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`).
12168. [x] Refactor for clarity and reliability while keeping tests green.
1317
@@ -15,7 +19,8 @@
1519
16201. [x] Review `docs/tui.md` requirements and translate into concrete UI sections and state model.
17212. [x] Add required dependencies for ratatui/crossterm/tokio/color-eyre/futures and pick a text input widget crate.
18−3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state transitions, and CLI argument parsing.
22+3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state
23+ transitions, and CLI argument parsing.
19244. [x] Implement TUI state model (header info, terminal history, input area, completion list, status bar visibility).
20255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
21266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
@@ -24,7 +29,9 @@
2429
2530## TUI
2631
27−
32+1. [ ] Fix the terminal component height issue.
33+2. [ ] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
34+ inputfield it should not be scrollable.
2835
2936## Integration
3037
install.shnew file+136
@@ -0,0 +1,136 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+APP=vibebox
5+REPO_URL="https://github.com/opencode-ai/vibebox"
6+REQUESTED_VERSION=${VERSION:-}
7+
8+RED='\033[0;31m'
9+GREEN='\033[0;32m'
10+YELLOW='\033[1;33m'
11+ORANGE='\033[38;2;255;140;0m'
12+NC='\033[0m' # No Color
13+
14+print_message() {
15+ local level=$1
16+ local message=$2
17+ local color=""
18+
19+ case $level in
20+ info) color="${GREEN}" ;;
21+ warning) color="${YELLOW}" ;;
22+ error) color="${RED}" ;;
23+ esac
24+
25+ echo -e "${color}${message}${NC}"
26+}
27+
28+require_cmd() {
29+ local cmd=$1
30+ local hint=$2
31+
32+ if ! command -v "$cmd" >/dev/null 2>&1; then
33+ print_message error "Missing required command: ${cmd}"
34+ if [[ -n "$hint" ]]; then
35+ print_message info "$hint"
36+ fi
37+ exit 1
38+ fi
39+}
40+
41+require_cmd cargo "Install Rust (cargo) from https://rustup.rs and retry."
42+require_cmd git "Install git and retry."
43+
44+CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}
45+INSTALL_DIR="$CARGO_HOME/bin"
46+
47+installed_version=""
48+if command -v "$APP" >/dev/null 2>&1; then
49+ installed_version=$("$APP" --version 2>/dev/null | awk '{print $2}' | head -n1 || true)
50+fi
51+
52+if [[ -n "$REQUESTED_VERSION" && "$installed_version" == "$REQUESTED_VERSION" ]]; then
53+ print_message info "Version ${YELLOW}$REQUESTED_VERSION${GREEN} already installed"
54+ exit 0
55+fi
56+
57+install_args=(install "$APP" --locked --git "$REPO_URL")
58+if [[ -n "$REQUESTED_VERSION" ]]; then
59+ install_args+=(--tag "v$REQUESTED_VERSION")
60+fi
61+
62+if command -v "$APP" >/dev/null 2>&1; then
63+ install_args+=(--force)
64+fi
65+
66+print_message info "Installing ${ORANGE}${APP}${GREEN}..."
67+print_message info "Using cargo install from ${ORANGE}${REPO_URL}${GREEN}..."
68+
69+cargo "${install_args[@]}"
70+
71+add_to_path() {
72+ local config_file=$1
73+ local command=$2
74+
75+ if [[ -w $config_file ]]; then
76+ echo -e "\n# vibebox" >> "$config_file"
77+ echo "$command" >> "$config_file"
78+ print_message info "Added ${ORANGE}${APP}${GREEN} to \$PATH in $config_file"
79+ else
80+ print_message warning "Manually add the directory to $config_file (or similar):"
81+ print_message info " $command"
82+ fi
83+}
84+
85+XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
86+current_shell=$(basename "$SHELL")
87+
88+case $current_shell in
89+ fish)
90+ config_files="$HOME/.config/fish/config.fish"
91+ ;;
92+ zsh)
93+ config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
94+ ;;
95+ bash)
96+ config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
97+ ;;
98+ ash)
99+ config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
100+ ;;
101+ sh)
102+ config_files="$HOME/.ashrc $HOME/.profile /etc/profile"
103+ ;;
104+ *)
105+ config_files="$HOME/.bashrc $HOME/.bash_profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
106+ ;;
107+esac
108+
109+config_file=""
110+for file in $config_files; do
111+ if [[ -f $file ]]; then
112+ config_file=$file
113+ break
114+ fi
115+done
116+
117+if [[ -z $config_file ]]; then
118+ print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
119+ exit 1
120+fi
121+
122+if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
123+ case $current_shell in
124+ fish)
125+ add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
126+ ;;
127+ *)
128+ add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
129+ ;;
130+ esac
131+fi
132+
133+if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
134+ echo "$INSTALL_DIR" >> "$GITHUB_PATH"
135+ print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
136+fi
src/tui.rs+97−11
@@ -23,6 +23,7 @@ use ratatui::{
2323 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
2424};
2525use tui_textarea::{Input, Key, TextArea};
26+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
2627
2728const ASCII_BANNER: [&str; 7] = [
2829 "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
@@ -57,13 +58,12 @@ pub struct AppState {
5758 tick: u64,
5859 spinner: usize,
5960 terminal_scroll: usize,
61+ input_view_width: u16,
6062}
6163
6264impl AppState {
6365 pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self {
64− let mut input = TextArea::default();
65− input.set_cursor_style(Style::default().fg(Color::Yellow));
66− input.set_block(Block::default().borders(Borders::ALL).title("Input"));
66+ let input = Self::default_input();
6767
6868 Self {
6969 cwd,
@@ -76,21 +76,92 @@ impl AppState {
7676 tick: 0,
7777 spinner: 0,
7878 terminal_scroll: 0,
79+ input_view_width: 0,
7980 }
8081 }
8182
82− pub fn input_line_count(&self) -> u16 {
83− self.input.lines().len().max(1) as u16
83+ fn default_input() -> TextArea<'static> {
84+ let mut input = TextArea::default();
85+ input.set_cursor_style(Style::default().fg(Color::Yellow));
86+ input.set_block(Block::default().borders(Borders::ALL).title("Input"));
87+ input
88+ }
89+
90+ fn reset_input(&mut self) {
91+ self.input = Self::default_input();
8492 }
8593
86− pub fn input_height(&self) -> u16 {
87− let mut height = self.input_line_count();
94+ fn take_input_text(&mut self) -> String {
95+ let text = self.input.lines().join("");
96+ self.reset_input();
97+ text
98+ }
99+
100+ pub fn input_height_for_width(&self, available_width: u16) -> u16 {
101+ let inner_width = self.input_inner_width(available_width).max(1) as usize;
102+ let mut visual_lines = 0usize;
103+ for line in self.input.lines() {
104+ let line_width = UnicodeWidthStr::width(line.as_str());
105+ let wrapped = if line_width == 0 {
106+ 1
107+ } else {
108+ (line_width + inner_width - 1) / inner_width
109+ };
110+ visual_lines += wrapped;
111+ }
112+
113+ let mut height = (visual_lines.max(1) as u16).max(1);
88114 if self.input.block().is_some() {
89115 height = height.saturating_add(2);
90116 }
91117 height.max(1)
92118 }
93119
120+ fn input_inner_width(&self, available_width: u16) -> u16 {
121+ if self.input.block().is_some() {
122+ available_width.saturating_sub(2)
123+ } else {
124+ available_width
125+ }
126+ }
127+
128+ fn insert_char_with_wrap(&mut self, c: char) {
129+ let char_width = UnicodeWidthChar::width(c).unwrap_or(0);
130+ let width = self.input_view_width.max(1) as usize;
131+ let mut should_wrap = false;
132+ if char_width > 0 && width > 0 {
133+ let (row, col) = self.input.cursor();
134+ if let Some(line) = self.input.lines().get(row) {
135+ if col == line.chars().count() {
136+ let line_width = UnicodeWidthStr::width(line.as_str());
137+ should_wrap = line_width + char_width > width;
138+ }
139+ }
140+ }
141+
142+ if should_wrap {
143+ if c == ' ' {
144+ self.input.insert_char(c);
145+ self.input.insert_newline();
146+ } else {
147+ self.input.insert_newline();
148+ self.input.insert_char(c);
149+ }
150+ } else {
151+ self.input.insert_char(c);
152+ }
153+ }
154+
155+ fn insert_str_with_wrap(&mut self, text: &str) {
156+ for c in text.chars() {
157+ match c {
158+ '\n' => self.insert_char_with_wrap(' '),
159+ '\r' => {}
160+ _ => self.insert_char_with_wrap(c),
161+ }
162+ }
163+ }
164+
94165 pub fn push_history(&mut self, line: impl Into<String>) {
95166 self.history.push(line.into());
96167 if self.history.len() > 2000 {
@@ -281,7 +352,7 @@ fn handle_event(event: CrosstermEvent, app: &mut AppState) {
281352 CrosstermEvent::Mouse(event) => handle_mouse_event(event, app),
282353 CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {}
283354 CrosstermEvent::Paste(text) => {
284− app.input.insert_str(&text);
355+ app.insert_str_with_wrap(&text);
285356 }
286357 }
287358}
@@ -325,7 +396,21 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
325396 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
326397 app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
327398 }
399+ KeyCode::Enter => {
400+ let message = app.take_input_text();
401+ if !message.trim().is_empty() {
402+ app.push_history(format!("> {}", message));
403+ app.terminal_scroll = 0;
404+ }
405+ }
328406 KeyCode::Tab => app.toggle_completions(default_completions()),
407+ KeyCode::Char(c)
408+ if !key
409+ .modifiers
410+ .contains(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
411+ {
412+ app.insert_char_with_wrap(c);
413+ }
329414 _ => {
330415 app.input.input(input_from_key_event(key));
331416 }
@@ -407,9 +492,10 @@ fn default_completions() -> Vec<String> {
407492
408493fn render(frame: &mut Frame<'_>, app: &mut AppState) {
409494 let area = frame.area();
495+ app.input_view_width = app.input_inner_width(area.width);
410496 let layout = compute_layout(
411497 area,
412− app.input_height(),
498+ app.input_height_for_width(area.width),
413499 app.completions.items().len(),
414500 app.completions.is_active(),
415501 );
@@ -491,14 +577,14 @@ fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
491577 let scroll_top = max_top.saturating_sub(terminal_scroll);
492578 let paragraph = Paragraph::new(Text::from_iter(lines))
493579 .block(block)
494− .wrap(Wrap { trim: false })
580+ .wrap(Wrap { trim: true })
495581 .scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
496582
497583 frame.render_widget(paragraph, area);
498584}
499585
500586fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
501− frame.render_widget(app.input.widget(), area);
587+ frame.render_widget(&app.input, area);
502588 if area.height > 0 && area.width > 0 {
503589 let cursor = app.input.cursor();
504590 let inner = match app.input.block() {