use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::{self, Write},
time::Duration,
};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
},
execute, queue,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
#[cfg(unix)]
use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crate::{prompt_input::PromptInput, CliResult};
use super::{
render::{
composer_input_width, composer_layout, queue_styled_line_at,
render_composer_lines_from_layout, render_footer_lines, render_live_history_lines,
terminal_error, StyledLine,
},
state::{
BodyScrollDirection, InteractiveTuiState, PendingSessionCommand, RunMode,
SteeringSubmission, COMPOSER_VISIBLE_LINES,
},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractiveTuiEvent {
Redraw,
Submit(PromptInput),
Steer(SteeringSubmission),
Session(Option<String>),
Clear,
PasteImage,
Cancel,
Quit,
}
pub struct InteractiveTui {
stdout: io::Stdout,
active: bool,
mouse_capture_enabled: bool,
keyboard_enhancements_enabled: bool,
rendered_body_cache: RenderedBodyCache,
frame_cache: FrameCache,
}
#[derive(Debug, Default)]
struct RenderedBodyCache {
signature: Option<BodyRenderSignature>,
lines: Vec<StyledLine>,
}
#[derive(Debug, Default)]
struct FrameCache {
width: usize,
height: usize,
lines: Vec<StyledLine>,
}
impl FrameCache {
fn reset_if_geometry_changed(&mut self, width: usize, height: usize) {
if self.width == width && self.height == height {
return;
}
self.width = width;
self.height = height;
self.lines.clear();
}
fn line_changed(&self, row: usize, line: &StyledLine) -> bool {
self.lines.get(row) != Some(line)
}
fn set_line(&mut self, row: usize, line: StyledLine) {
if self.lines.len() <= row {
self.lines
.resize_with(row.saturating_add(1), || StyledLine::plain(""));
}
self.lines[row] = line;
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct BodyRenderSignature {
width: usize,
workspace_dir: String,
model: String,
render_mode: crate::args::TuiRenderMode,
timeline_generation: u64,
body_len: usize,
body_total_bytes: usize,
body_hash: u64,
}
impl InteractiveTui {
pub fn enter() -> CliResult<Self> {
let mut stdout = io::stdout();
terminal::enable_raw_mode().map_err(terminal_error)?;
if let Err(error) = execute!(
stdout,
EnterAlternateScreen,
EnableBracketedPaste,
EnableMouseCapture,
Hide
) {
let _ = terminal::disable_raw_mode();
return Err(terminal_error(error));
}
let keyboard_enhancements_enabled = enable_keyboard_enhancements(&mut stdout);
Ok(Self {
stdout,
active: true,
mouse_capture_enabled: true,
keyboard_enhancements_enabled,
rendered_body_cache: RenderedBodyCache::default(),
frame_cache: FrameCache::default(),
})
}
pub fn render(&mut self, state: &mut InteractiveTuiState) -> CliResult<()> {
self.sync_mouse_capture(should_capture_mouse(state))?;
let (width, height) = terminal::size().unwrap_or((80, 24));
let width = if width == 0 { 80 } else { width };
let height = if height == 0 { 24 } else { height };
let terminal_width = usize::from(width);
let render_width = terminal_width.saturating_sub(1).max(1);
let height = usize::from(height).max(8);
let input_width = composer_input_width(render_width);
let composer_layout = composer_layout(
&state.input,
state.composer_cursor_byte(),
COMPOSER_VISIBLE_LINES,
state.composer_scroll_offset(),
input_width,
);
let composer_lines =
render_composer_lines_from_layout(state, render_width, &composer_layout);
let status_lines = render_footer_lines(state, render_width);
let fixed_height = composer_lines.len().saturating_add(status_lines.len());
let body_height = height.saturating_sub(fixed_height).max(1);
let visible_body = {
let rendered_body = self.rendered_body_lines(state, render_width);
let rendered_body_len = rendered_body.len();
state.update_render_metrics(rendered_body_len, body_height);
let (visible_start, visible_end) =
visible_body_bounds(state, rendered_body_len, body_height);
rendered_body[visible_start..visible_end].to_vec()
};
let mut frame_lines = vec![StyledLine::plain(""); height];
for (row, slot) in frame_lines
.iter_mut()
.enumerate()
.take(body_height.min(height))
{
if let Some(line) = visible_body.get(row) {
*slot = line.clone();
}
}
let status_start = height.saturating_sub(fixed_height);
for (offset, line) in status_lines.iter().enumerate() {
let row = status_start.saturating_add(offset);
if let Some(slot) = frame_lines.get_mut(row) {
*slot = line.clone();
}
}
let composer_start = status_start.saturating_add(status_lines.len());
for (offset, line) in composer_lines.iter().enumerate() {
let row = composer_start.saturating_add(offset);
if let Some(slot) = frame_lines.get_mut(row) {
*slot = line.clone();
}
}
self.frame_cache
.reset_if_geometry_changed(render_width, height);
let changed_rows = frame_lines
.iter()
.enumerate()
.filter(|(row, line)| self.frame_cache.line_changed(*row, line))
.collect::<Vec<_>>();
if !changed_rows.is_empty() {
queue!(self.stdout, Hide).map_err(terminal_error)?;
for (row, line) in changed_rows {
queue_styled_line_at(
&mut self.stdout,
u16::try_from(row).unwrap_or(u16::MAX),
line,
render_width,
)?;
self.frame_cache.set_line(row, line.clone());
}
}
let cursor_row = composer_start.saturating_add(1).saturating_add(
composer_layout
.cursor_line
.saturating_sub(composer_layout.visible_start)
.min(composer_layout.visible_lines.len().saturating_sub(1)),
);
let cursor_col = 2usize.saturating_add(composer_layout.cursor_col);
queue!(
self.stdout,
MoveTo(
u16::try_from(cursor_col.min(render_width.saturating_sub(1))).unwrap_or(u16::MAX),
u16::try_from(cursor_row).unwrap_or(u16::MAX),
),
Show
)
.map_err(terminal_error)?;
self.stdout.flush().map_err(terminal_error)
}
fn rendered_body_lines(&mut self, state: &InteractiveTuiState, width: usize) -> &[StyledLine] {
let signature = body_render_signature(state, width);
if self.rendered_body_cache.signature.as_ref() != Some(&signature) {
self.rendered_body_cache.lines = render_live_history_lines(state, width);
self.rendered_body_cache.signature = Some(signature);
}
&self.rendered_body_cache.lines
}
fn sync_mouse_capture(&mut self, should_enable: bool) -> CliResult<()> {
if self.mouse_capture_enabled == should_enable {
return Ok(());
}
if should_enable {
execute!(self.stdout, EnableMouseCapture).map_err(terminal_error)?;
} else {
execute!(self.stdout, DisableMouseCapture).map_err(terminal_error)?;
}
self.mouse_capture_enabled = should_enable;
Ok(())
}
pub fn poll_event(
state: &mut InteractiveTuiState,
timeout: Duration,
) -> CliResult<Option<InteractiveTuiEvent>> {
if !event::poll(timeout).map_err(terminal_error)? {
return Ok(None);
}
match event::read().map_err(terminal_error)? {
Event::Key(key)
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
{
Ok(handle_key_event(state, key).or(Some(InteractiveTuiEvent::Redraw)))
}
Event::Paste(text) => {
state.apply_paste(&text);
Ok(Some(InteractiveTuiEvent::Redraw))
}
Event::Mouse(mouse) => Ok(handle_mouse_event(state, mouse)),
Event::Resize(_, _) => Ok(Some(InteractiveTuiEvent::Redraw)),
_ => Ok(None),
}
}
}
fn body_render_signature(state: &InteractiveTuiState, width: usize) -> BodyRenderSignature {
let mut body_hasher = DefaultHasher::new();
for line in &state.body {
line.hash(&mut body_hasher);
}
BodyRenderSignature {
width,
workspace_dir: state.workspace_dir.clone(),
model: state.model.clone(),
render_mode: state.render_mode(),
timeline_generation: state.timeline_generation(),
body_len: state.body.len(),
body_total_bytes: state.body.iter().map(String::len).sum(),
body_hash: body_hasher.finish(),
}
}
fn scroll_viewport(
state: &mut InteractiveTuiState,
amount: usize,
direction: BodyScrollDirection,
) -> bool {
state.scroll_body(amount, direction)
}
pub(super) fn handle_mouse_event(
state: &mut InteractiveTuiState,
mouse: MouseEvent,
) -> Option<InteractiveTuiEvent> {
match mouse.kind {
MouseEventKind::ScrollUp => scroll_viewport(state, 3, BodyScrollDirection::Up)
.then_some(InteractiveTuiEvent::Redraw),
MouseEventKind::ScrollDown => scroll_viewport(state, 3, BodyScrollDirection::Down)
.then_some(InteractiveTuiEvent::Redraw),
_ => None,
}
}
fn session_command_event(command: PendingSessionCommand) -> InteractiveTuiEvent {
match command {
PendingSessionCommand::Current => InteractiveTuiEvent::Session(None),
PendingSessionCommand::Select(session_id) => InteractiveTuiEvent::Session(Some(session_id)),
}
}
pub(super) const fn should_capture_mouse(state: &InteractiveTuiState) -> bool {
!state.selection_mode_visible()
}
pub(super) fn visible_body_bounds(
state: &InteractiveTuiState,
rendered_body_len: usize,
body_height: usize,
) -> (usize, usize) {
let max_scroll = rendered_body_len.saturating_sub(body_height);
let visible_start = if state.is_at_bottom() {
max_scroll
} else {
state.scroll_offset.min(max_scroll)
};
let visible_end = visible_start
.saturating_add(body_height)
.min(rendered_body_len);
(visible_start, visible_end)
}
fn command_modifier(modifiers: KeyModifiers) -> bool {
modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::META)
}
fn word_modifier(modifiers: KeyModifiers) -> bool {
modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL)
}
#[cfg(unix)]
fn enable_keyboard_enhancements(stdout: &mut io::Stdout) -> bool {
execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)
.is_ok()
}
#[cfg(not(unix))]
fn enable_keyboard_enhancements(stdout: &mut io::Stdout) -> bool {
let _ = stdout;
false
}
#[allow(clippy::too_many_lines)]
pub(super) fn handle_key_event(
state: &mut InteractiveTuiState,
key: KeyEvent,
) -> Option<InteractiveTuiEvent> {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& state.running
{
state.request_cancel();
return Some(InteractiveTuiEvent::Cancel);
}
if state.session_picker_visible() {
match key.code {
KeyCode::Esc => state.close_session_picker(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.close_session_picker();
}
KeyCode::Enter => {
state.select_session_picker_choice();
return state
.take_pending_session_command()
.map(session_command_event);
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Up);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Down);
}
KeyCode::PageUp => {
scroll_viewport(state, 10, BodyScrollDirection::Up);
}
KeyCode::PageDown => {
scroll_viewport(state, 10, BodyScrollDirection::Down);
}
KeyCode::Up => state.move_session_picker_selection(-1),
KeyCode::Down => state.move_session_picker_selection(1),
_ => {}
}
return None;
}
if state.model_picker_visible() {
match key.code {
KeyCode::Esc => state.close_model_picker(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.close_model_picker();
}
KeyCode::Enter => state.select_model_picker_choice(),
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Up);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Down);
}
KeyCode::PageUp => {
scroll_viewport(state, 10, BodyScrollDirection::Up);
}
KeyCode::PageDown => {
scroll_viewport(state, 10, BodyScrollDirection::Down);
}
KeyCode::Up => state.move_model_picker_selection(-1),
KeyCode::Down => state.move_model_picker_selection(1),
_ => {}
}
return None;
}
if state.selection_mode_visible() {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => state.close_selection_mode(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.close_selection_mode();
}
KeyCode::PageUp => {
state.move_selection(-10);
scroll_viewport(state, 10, BodyScrollDirection::Up);
}
KeyCode::PageDown => {
state.move_selection(10);
scroll_viewport(state, 10, BodyScrollDirection::Down);
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Up);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Down);
}
KeyCode::Up => state.move_selection(-1),
KeyCode::Down => state.move_selection(1),
_ => {}
}
return None;
}
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.composer_is_empty() {
return Some(InteractiveTuiEvent::Quit);
}
state.clear_composer();
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.running {
state.show_run_active_hint();
} else {
return Some(InteractiveTuiEvent::Quit);
}
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.scroll_to_bottom();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.clear_composer();
}
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.move_composer_cursor_to_line_start();
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.move_composer_cursor_to_line_end();
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.move_composer_cursor_left();
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.move_composer_cursor_right();
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::ALT) => {
state.move_composer_cursor_word_left();
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::ALT) => {
state.move_composer_cursor_word_right();
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
state.scroll_composer_up(1);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
state.scroll_composer_down(1);
}
KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Some(InteractiveTuiEvent::PasteImage);
}
KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.insert_composer_newline();
}
KeyCode::Char('p' | 'r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.previous_history();
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.next_history();
}
KeyCode::Esc => {
state.open_selection_mode();
}
KeyCode::Char('q') if state.composer_is_empty() => {
if state.running {
state.show_run_active_hint();
} else {
return Some(InteractiveTuiEvent::Quit);
}
}
KeyCode::BackTab => {
state.run_mode = match state.run_mode {
RunMode::Act => RunMode::Plan,
RunMode::Plan => RunMode::Act,
};
}
KeyCode::Tab => {
state.toggle_enter_mode();
}
KeyCode::Enter if !state.enter_sends() => {
state.insert_composer_newline();
}
KeyCode::Enter if state.running => {
if state.take_paste_image_command() {
return Some(InteractiveTuiEvent::PasteImage);
}
if let Some(steering) = state.take_steering_prompt() {
state.push_history(steering.text.clone());
return Some(InteractiveTuiEvent::Steer(steering));
}
}
KeyCode::Enter => {
if state.take_paste_image_command() {
return Some(InteractiveTuiEvent::PasteImage);
}
if let Some(prompt) = state.take_submission_prompt() {
state.push_history(prompt.display_text());
return Some(InteractiveTuiEvent::Submit(prompt));
}
if state.take_pending_clear_context() {
return Some(InteractiveTuiEvent::Clear);
}
if let Some(session) = state.take_pending_session_command() {
return Some(session_command_event(session));
}
}
KeyCode::Backspace => {
state.backspace_composer();
}
KeyCode::Left if command_modifier(key.modifiers) => {
state.move_composer_cursor_to_line_start();
}
KeyCode::Right if command_modifier(key.modifiers) => {
state.move_composer_cursor_to_line_end();
}
KeyCode::Left if word_modifier(key.modifiers) => {
state.move_composer_cursor_word_left();
}
KeyCode::Right if word_modifier(key.modifiers) => {
state.move_composer_cursor_word_right();
}
KeyCode::Left => {
state.move_composer_cursor_left();
}
KeyCode::Right => {
state.move_composer_cursor_right();
}
KeyCode::Home => {
state.move_composer_cursor_to_line_start();
}
KeyCode::End => {
state.move_composer_cursor_to_line_end();
}
KeyCode::PageUp => {
scroll_viewport(state, 10, BodyScrollDirection::Up);
}
KeyCode::PageDown => {
scroll_viewport(state, 10, BodyScrollDirection::Down);
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Up);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
scroll_viewport(state, 1, BodyScrollDirection::Down);
}
KeyCode::Up => state.previous_history(),
KeyCode::Down => state.next_history(),
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
state.push_composer_char(ch);
}
_ => {}
}
None
}
impl Drop for InteractiveTui {
fn drop(&mut self) {
if self.active {
if self.keyboard_enhancements_enabled {
#[cfg(unix)]
let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
}
let _ = execute!(
self.stdout,
Show,
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
);
let _ = terminal::disable_raw_mode();
self.active = false;
}
}
}