Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .chatty.default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ bubble_width_percent = 60
auto_start = false
# Enable bubble chat or not. Default is true
bubble = true
# Show wrapper character or not.
show_wrapped_indicator = true
# Automatically copy selected text to clipboard
copy_on_select = false


[log]
# Default log level is "info"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ multiple model support, and intelligent context compression.
* **Syntax Highlighting**: Beautiful code block colorization
* **Fast and Efficient**: Written in Rust for optimal performance

## 💡 Tips
* Copy most recent message `Ctrl + e >> c`
* Press `y` to copy selected message.

## Quick Start

### Installation
Expand Down
127 changes: 122 additions & 5 deletions src/app/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::models::conversation::FindMessage;
use crate::models::{BackendPrompt, Conversation, Event, Message, message::Issuer};
use crate::models::{BackendResponse, Model, UpsertConvoRequest};
use crate::{info_notice, warn_notice};
use crossterm::event::MouseButton;
use eyre::Result;
use ratatui::{
Terminal,
Expand All @@ -21,6 +22,7 @@ use syntect::highlighting::Theme;
use tokio::sync::mpsc;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tui_textarea::Key;

use crate::{
app::app_state::AppState,
Expand All @@ -30,6 +32,9 @@ use crate::{
};

use super::services::EventService;
use super::ui::selection::Selection;
use super::ui::utils::is_wrapper_span;
use super::ui::{Content, Selectable};
use super::{destruct_terminal, init_terminal};

const MIN_WIDTH: u16 = 80;
Expand All @@ -51,6 +56,7 @@ pub struct App<'a> {
edit_screen: EditScreen<'a>,
history_screen: HistoryScreen<'a>,
input: tui_textarea::TextArea<'a>,
selection: Selection,

compressor: Arc<Compressor>,

Expand Down Expand Up @@ -94,6 +100,7 @@ impl<'a> App<'a> {
.with_current_conversation(""),
models_screen: ModelsScreen::new(init_props.models, event_tx.clone()),
notice: Notice::default(),
selection: Selection::default(),
cancel_token,
}
}
Expand Down Expand Up @@ -225,8 +232,20 @@ impl<'a> App<'a> {
async fn handle_input_event(&mut self, event: Event) {
// Handle input events
match event {
Event::KeyboardEsc => {
if !self.selection.is_empty() {
self.selection.clear();
}
}

Event::KeyboardCharInput(c) => {
if !self.selection.is_empty() && matches!(c.key, Key::Char('y')) {
self.handle_copy_selection(true);
return;
}

if !self.app_state.waiting_for_backend {
self.selection.clear();
self.input.input(c);
}
}
Expand All @@ -244,35 +263,51 @@ impl<'a> App<'a> {
}
}

Event::KeyboardF1 => self.help_screen.toggle_showing(),
Event::KeyboardF1 => {
self.selection.clear();
self.help_screen.toggle_showing()
}

Event::KeyboardCtrlN => self.handle_new_conversation(),
Event::KeyboardCtrlN => {
self.selection.clear();
self.handle_new_conversation()
}

Event::KeyboardCtrlH => {
if !self.on_waiting_backend(true) {
self.selection.clear();
self.history_screen.toggle_showing();
}
}

Event::KeyboardCtrlL => self.models_screen.toggle_showing(),
Event::KeyboardCtrlL => {
self.selection.clear();
self.models_screen.toggle_showing()
}

Event::KeyboardCtrlE => {
if !self.on_waiting_backend(true) {
self.selection.clear();
self.edit_screen
.set_messages(self.app_state.current_convo.messages());
self.edit_screen.toggle_showing();
}
}

Event::KeyboardCtrlR => self.handle_regenerate_response().await,
Event::KeyboardCtrlR => {
self.selection.clear();
self.handle_regenerate_response().await
}

Event::KeyboardPaste(text) => {
self.selection.clear();
self.input.set_yank_text(text.replace('\r', "\n"));
self.input.paste();
}

Event::KeyboardNewLine => {
if !self.on_waiting_backend(false) {
self.selection.clear();
self.input.insert_newline();
}
}
Expand All @@ -283,6 +318,10 @@ impl<'a> App<'a> {
Event::UiScrollUp => self.app_state.scroll.up(),
Event::UiScrollPageDown => self.app_state.scroll.page_down(),
Event::UiScrollPageUp => self.app_state.scroll.page_up(),

Event::UiMouseUp { button, x, y } => self.handle_mouse_click(false, button, x, y),
Event::UiMouseDown { button, x, y } => self.handle_mouse_click(true, button, x, y),
Event::UiMouseDrag { button, x, y } => self.handle_mouse_drag(button, x, y),
_ => {}
}
}
Expand Down Expand Up @@ -325,7 +364,8 @@ impl<'a> App<'a> {
self.app_state.bubble_list.render(
layout[0],
f.buffer_mut(),
self.app_state.scroll.position.try_into().unwrap(),
self.app_state.scroll.position,
&self.selection,
);

f.render_stateful_widget(
Expand Down Expand Up @@ -375,10 +415,87 @@ impl<'a> App<'a> {
}
}

fn handle_copy_selection(&self, notice: bool) {
let Some((start, end)) = self.selection.get_bounds() else {
return;
};
let start_row = start.row;
let end_row = end.row;

let lines = &self.app_state.bubble_list.lines()[start_row..end_row + 1];

let mut spans = vec![];
for (i, line) in lines.iter().enumerate() {
if line.is_selectable() && self.selection.contains_row(i + start_row) {
let line = self
.selection
.format_line(line.as_ref().clone(), i + start_row);
let mut wrapped = false;
spans.extend(line.spans.into_iter().filter(|s| {
if is_wrapper_span(s) {
wrapped = true;
}
s.is_selectable() && s.is_highlighted()
}));
if !wrapped {
spans.push(span!("\n"));
}
}
}
let _ = self.action_tx.send(Action::CopyText {
content: spans.content(),
notice,
});
}

fn handle_mouse_click(&mut self, down: bool, button: MouseButton, x: u16, y: u16) {
if down {
self.selection.clear();
self.handle_mouse_drag(button, x, y);
return;
}

if button != MouseButton::Left {
return;
}
self.handle_mouse_drag(button, x, y);
if self.selection.start() == self.selection.end() {
self.selection.clear();
}

if !self.selection.is_empty()
&& config::instance()
.general
.copy_on_select
.unwrap_or_default()
{
self.handle_copy_selection(false);
}
}

fn handle_mouse_drag(&mut self, button: MouseButton, x: u16, y: u16) {
if button == MouseButton::Left {
let scroll_index: u16 = self.app_state.scroll.position.try_into().unwrap();
let Some((row, col)) =
self.app_state
.bubble_list
.screen_pos_to_line_pos(x, y, scroll_index)
else {
return;
};
if self.selection.start().is_none() {
self.selection.set_start(row, col);
} else {
self.selection.set_end(row, col);
}
}
}

fn handle_send_prompt(&mut self) {
if self.on_waiting_backend(false) {
return;
}
self.selection.clear();

let input_str = &self.input.lines().join("\n").trim().to_string();
if input_str.is_empty() {
Expand Down
21 changes: 18 additions & 3 deletions src/app/services/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ impl ActionService {
}
}

Action::CopyText { content, notice } => {
if let Err(err) = self.copy_text(content, notice).await {
log::error!("Failed to copy text: {}", err);
let _ = self
.event_tx
.send(error_event!(format!("Failed to copy text: {}", err)));
}
}

Action::DeleteConversation(id) => self.process_delete_convo(&id).await,
Action::UpsertConversation(req) => self.process_upsert_convo(req).await,
Action::UpsertMessage(convo_id, message) => {
Expand Down Expand Up @@ -222,9 +231,15 @@ impl ActionService {
}

ClipboardService::set(payload)?;
let _ = self
.event_tx
.send(info_event!("Copied messages to clipboard!"));
let _ = self.event_tx.send(info_event!("Copied to clipboard!"));
Ok(())
}

async fn copy_text(&self, content: String, notice: bool) -> Result<()> {
ClipboardService::set(content)?;
if notice {
let _ = self.event_tx.send(info_event!("Copied to clipboard!"));
}
Ok(())
}

Expand Down
17 changes: 16 additions & 1 deletion src/app/services/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,26 @@ impl EventService {
CrosstermEvent::Mouse(mouse_event) => match mouse_event.kind {
MouseEventKind::ScrollUp => Some(Event::UiScrollUp),
MouseEventKind::ScrollDown => Some(Event::UiScrollDown),
MouseEventKind::Up(button) => Some(Event::UiMouseUp {
button,
x: mouse_event.column,
y: mouse_event.row,
}),
MouseEventKind::Down(button) => Some(Event::UiMouseDown {
button,
x: mouse_event.column,
y: mouse_event.row,
}),
MouseEventKind::Drag(button) => Some(Event::UiMouseDrag {
button,
x: mouse_event.column,
y: mouse_event.row,
}),
_ => None,
},
CrosstermEvent::Key(key_event) => {
let input: Input = key_event.into();
if input.key == Key::Enter && (input.shift || input.alt || input.ctrl) {
if input.key == Key::Enter && (input.shift || input.alt) {
return Some(Event::KeyboardNewLine);
}

Expand Down
Loading