Inital Commit for VIVO-Temrinal
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+3268
File diff suppressed because it is too large
Load Diff
+15
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "VIVO-Terminal"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
color-eyre = "0.6.5"
|
||||||
|
ratatui = "0.30.2"
|
||||||
|
crossterm = "0.29.0"
|
||||||
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.13.4", features = ["json"] }
|
||||||
|
axum = "0.8.9"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
serde_json = "1.0.151"
|
||||||
|
textwrap = "0.16.2"
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
mod rest;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
use crossterm::event;
|
||||||
|
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{DefaultTerminal, Frame};
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Layout, Position};
|
||||||
|
use ratatui::prelude::Direction;
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
||||||
|
use crate::rest::REST;
|
||||||
|
use textwrap::wrap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> color_eyre::Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
let result = App::new().run(&mut terminal);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
input: String,
|
||||||
|
should_quit: bool,
|
||||||
|
pos: usize,
|
||||||
|
messages: Arc<Mutex<Vec<String>>>,
|
||||||
|
rest: REST,
|
||||||
|
list_state: ListState,
|
||||||
|
reply_rx: mpsc::UnboundedReceiver<String>,
|
||||||
|
reply_tx: mpsc::UnboundedSender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
App{
|
||||||
|
input: String::new(),
|
||||||
|
should_quit: false,
|
||||||
|
pos: 0,
|
||||||
|
messages: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
rest: REST::new(),
|
||||||
|
list_state: ListState::default(),
|
||||||
|
reply_rx: rx,
|
||||||
|
reply_tx: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
|
||||||
|
while !self.should_quit {
|
||||||
|
terminal.draw(|frame| self.render(frame))?;
|
||||||
|
if crossterm::event::poll(Duration::from_millis(500))? {
|
||||||
|
self.handle_event()?;
|
||||||
|
}
|
||||||
|
self.drain_replies();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(&mut self) -> std::io::Result<()> {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if(key.modifiers == KeyModifiers::CONTROL)
|
||||||
|
{
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.enter_char(c)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Left => self.move_cursor_left(),
|
||||||
|
KeyCode::Right => self.move_cursor_right(),
|
||||||
|
KeyCode::Home => self.reset_cursor(),
|
||||||
|
KeyCode::End => self.pos = self.input.len() - 1,
|
||||||
|
KeyCode::Backspace => self.delete_char(),
|
||||||
|
KeyCode::Delete => self.delete_forward_char(),
|
||||||
|
KeyCode::Esc => self.should_quit = true,
|
||||||
|
KeyCode::Tab => self.should_quit = true,
|
||||||
|
KeyCode::Up => self.move_view_up(),
|
||||||
|
KeyCode::Down => self.move_view_down(),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let strMessage = self.input.clone();
|
||||||
|
|
||||||
|
self.push_message(strMessage.clone());
|
||||||
|
|
||||||
|
self.reset_cursor();
|
||||||
|
self.input = String::new();
|
||||||
|
let message = rest::Message::new(
|
||||||
|
strMessage.clone(),
|
||||||
|
"USER".parse().unwrap(),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let tx = self.reply_tx.clone();
|
||||||
|
let mut rest = self.rest.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match rest.send(message).await {
|
||||||
|
Ok(reply) => {
|
||||||
|
if(reply.role == "ASSISTANT")
|
||||||
|
{
|
||||||
|
let _ = tx.send(reply.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_replies(&mut self) {
|
||||||
|
while let Ok(msg) = self.reply_rx.try_recv() {
|
||||||
|
self.push_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_view_down(&mut self) {
|
||||||
|
let i = match self.list_state.selected() {
|
||||||
|
Some(i) => (i+1).min(self.messages.lock().unwrap().len().saturating_sub(1)),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_view_up(&mut self) {
|
||||||
|
let i = match self.list_state.selected() {
|
||||||
|
Some(i) => i.saturating_sub(1),
|
||||||
|
None => self.messages.lock().unwrap().len().saturating_sub(1),
|
||||||
|
};
|
||||||
|
self.list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_message(&mut self, message: String) {
|
||||||
|
let mut messages = self.messages.lock().unwrap();
|
||||||
|
messages.push(message);
|
||||||
|
let last = messages.len().saturating_sub(1);
|
||||||
|
self.list_state.select(Some(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_char(&mut self) {
|
||||||
|
let is_not_cursor_leftmost = self.pos != 0;
|
||||||
|
if is_not_cursor_leftmost {
|
||||||
|
let current_index = self.pos;
|
||||||
|
let from_left_to_current_index = current_index - 1;
|
||||||
|
|
||||||
|
let before_chat_to_delete = self.input.chars().take(from_left_to_current_index);
|
||||||
|
let after_chat_to_delete = self.input.chars().skip(current_index);
|
||||||
|
self.input = before_chat_to_delete.chain(after_chat_to_delete).collect();
|
||||||
|
self.move_cursor_left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_forward_char(&mut self) {
|
||||||
|
let is_not_the_rightmost = self.pos != self.input.len();
|
||||||
|
if is_not_the_rightmost {
|
||||||
|
let current_index = self.pos;
|
||||||
|
let from_left_to_current_index = current_index;
|
||||||
|
|
||||||
|
let before_chat_to_delete = self.input.chars().take(from_left_to_current_index);
|
||||||
|
let after_chat_to_delete = self.input.chars().skip(from_left_to_current_index+1);
|
||||||
|
|
||||||
|
self.input = before_chat_to_delete.chain(after_chat_to_delete).collect();
|
||||||
|
//self.move_cursor_left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_cursor_left(&mut self) {
|
||||||
|
let pos = self.pos.saturating_sub(1);
|
||||||
|
self.pos = self.clamp_cursor(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_cursor_right(&mut self) {
|
||||||
|
let pos = self.pos.saturating_add(1);
|
||||||
|
self.pos = self.clamp_cursor(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_cursor(&self, pos: usize) -> usize {
|
||||||
|
pos.clamp(0, self.input.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_cursor(&mut self) {
|
||||||
|
self.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_char(&mut self, new_char: char) {
|
||||||
|
let index = self.byte_index();
|
||||||
|
self.input.insert(index, new_char);
|
||||||
|
self.move_cursor_right();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_index(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.char_indices()
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.nth(self.pos)
|
||||||
|
.unwrap_or(self.input.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, frame: &mut Frame) {
|
||||||
|
let layout = Layout::vertical([
|
||||||
|
Constraint::Percentage(70),
|
||||||
|
Constraint::Min(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let [chat_area, input_area] = frame.area().layout(&layout);
|
||||||
|
|
||||||
|
if chat_area.height < 10 {
|
||||||
|
// too small — render a warning instead of your normal UI
|
||||||
|
let warning = Paragraph::new("Terminal window too small!")
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(warning, frame.area());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = self.messages.lock().unwrap();
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, msg)| {
|
||||||
|
let mut lines: Vec<Line>= wrap(msg.as_str(), chat_area.width.saturating_sub(2) as usize)
|
||||||
|
.iter()
|
||||||
|
.map(|s| Line::from(s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if i != messages.len() - 1 {
|
||||||
|
// separator row — width matches the area you render into
|
||||||
|
let sep = "─".repeat(chat_area.width as usize);
|
||||||
|
lines.push(Line::styled(sep, Style::default().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem::new(lines)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Messages"));
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, chat_area, &mut self.list_state);
|
||||||
|
|
||||||
|
|
||||||
|
let inner_width = input_area.width.saturating_sub(2) as usize;
|
||||||
|
|
||||||
|
let wrapped_lines = wrap(&self.input, inner_width);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(self.input.as_str())
|
||||||
|
.block(Block::new().borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
input_area,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (cursor_line, cursor_col) = {
|
||||||
|
let mut remaining = self.pos;
|
||||||
|
let mut line_idx = 0;
|
||||||
|
let mut col = 0;
|
||||||
|
|
||||||
|
for (i, line) in wrapped_lines.iter().enumerate() {
|
||||||
|
let len = line.chars().count();
|
||||||
|
if remaining <= len {
|
||||||
|
line_idx = i;
|
||||||
|
col = remaining;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining -= len;
|
||||||
|
line_idx = i + 1;
|
||||||
|
col = remaining; // handles case where pos is exactly at end of input
|
||||||
|
}
|
||||||
|
|
||||||
|
(line_idx, col)
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.set_cursor_position(Position::new(
|
||||||
|
input_area.x + cursor_col as u16 + 1,
|
||||||
|
input_area.y + cursor_line as u16 + 1,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Message {
|
||||||
|
pub message: String,
|
||||||
|
pub role: String,
|
||||||
|
pub finished: bool,
|
||||||
|
pub no_voice: bool,
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn new(message: String, role: String, finished: bool, no_voice: bool, stream: bool) -> Self {
|
||||||
|
Message {
|
||||||
|
message,
|
||||||
|
role,
|
||||||
|
finished,
|
||||||
|
no_voice,
|
||||||
|
stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct REST {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl REST {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
REST{
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&mut self, mut message: Message) -> Result<Message, reqwest::Error> {
|
||||||
|
// enforcing this as the current REST API stack dose not support this.
|
||||||
|
message.stream = false;
|
||||||
|
let res = self.client
|
||||||
|
.post("http://localhost:8080/v1/message/send")
|
||||||
|
.json(&message)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Message>()
|
||||||
|
.await?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user