@@ -11,28 +11,29 @@ use crossterm::{
1111 KeyEvent, KeyEventKind, KeyModifiers,
1212 },
1313 execute,
14− terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
14+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
1515};
1616use futures::StreamExt;
1717use ratatui::{
18− Frame, Terminal,
19− backend::CrosstermBackend,
18+ backend::CrosstermBackend, buffer::Buffer,
2019 layout::{Constraint, Direction, Layout, Rect},
2120 style::{Color, Style},
2221 text::{Line, Span, Text},
23− widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
22+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap},
23+ Frame,
24+ Terminal,
2425};
2526use tui_textarea::{Input, Key, TextArea};
2627use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
2728
2829const ASCII_BANNER: [&str; 7] = [
29− "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
30− "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
31− " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
32− " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░",
33− " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
34− " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░",
35− " ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░",
30+ "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
31+ "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
32+ "██║ ██║██║██████╔╝█████╗ ██████╔╝██║ ██║ ╚███╔╝",
33+ "╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██╔══██╗██║ ██║ ██╔██╗",
34+ " ╚████╔╝ ██║██████╔╝███████╗██████╔╝╚██████╔╝██╔╝ ██╗",
35+ " ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
36+ "",
3637];
3738
3839const STATUS_BAR_HEIGHT: u16 = 1;
@@ -57,7 +58,7 @@ pub struct AppState {
5758 key_input_mode: KeyInputMode,
5859 tick: u64,
5960 spinner: usize,
60− terminal_scroll: usize,
61+ page_scroll: usize,
6162 input_view_width: u16,
6263}
6364
@@ -75,7 +76,7 @@ impl AppState {
7576 key_input_mode: KeyInputMode::Unknown,
7677 tick: 0,
7778 spinner: 0,
78− terminal_scroll: 0,
79+ page_scroll: 0,
7980 input_view_width: 0,
8081 }
8182 }
@@ -256,6 +257,7 @@ impl CompletionState {
256257 }
257258}
258259
260+#[allow(dead_code)]
259261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260262pub struct LayoutAreas {
261263 pub header: Rect,
@@ -265,6 +267,17 @@ pub struct LayoutAreas {
265267 pub status: Rect,
266268}
267269
270+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
271+struct PageLayout {
272+ header: Rect,
273+ terminal: Rect,
274+ input: Rect,
275+ completions: Rect,
276+ status: Rect,
277+ total_height: u16,
278+}
279+
280+#[allow(dead_code)]
268281pub fn compute_layout(
269282 area: Rect,
270283 input_height: u16,
@@ -310,6 +323,62 @@ pub fn compute_layout(
310323 }
311324}
312325
326+fn compute_page_layout(app: &AppState, width: u16) -> PageLayout {
327+ let header_height = header_height();
328+ let terminal_height = terminal_height(app, width);
329+ let input_height = app.input_height_for_width(width);
330+ let (completion_height, status_height) = if app.completions.is_active() {
331+ let desired = (app.completions.items().len() as u16).min(COMPLETIONS_MAX_HEIGHT);
332+ (desired.saturating_add(2), 0)
333+ } else {
334+ (0, STATUS_BAR_HEIGHT)
335+ };
336+
337+ let total_height = header_height
338+ .saturating_add(terminal_height)
339+ .saturating_add(input_height)
340+ .saturating_add(completion_height)
341+ .saturating_add(status_height)
342+ .max(1);
343+
344+ let mut y = 0u16;
345+ let header = Rect::new(0, y, width, header_height);
346+ y = y.saturating_add(header_height);
347+ let terminal = Rect::new(0, y, width, terminal_height);
348+ y = y.saturating_add(terminal_height);
349+ let input = Rect::new(0, y, width, input_height);
350+ y = y.saturating_add(input_height);
351+ let completions = Rect::new(0, y, width, completion_height);
352+ y = y.saturating_add(completion_height);
353+ let status = Rect::new(0, y, width, status_height);
354+
355+ PageLayout {
356+ header,
357+ terminal,
358+ input,
359+ completions,
360+ status,
361+ total_height,
362+ }
363+}
364+
365+fn terminal_height(app: &AppState, width: u16) -> u16 {
366+ if width == 0 {
367+ return 0;
368+ }
369+ let lines: Vec<Line> = app
370+ .history
371+ .iter()
372+ .map(|line| Line::from(line.as_str()))
373+ .collect();
374+ let block = Block::default().borders(Borders::ALL).title("Terminal");
375+ let paragraph = Paragraph::new(Text::from_iter(lines))
376+ .block(block)
377+ .wrap(Wrap { trim: true });
378+ let count = paragraph.line_count(width).max(1);
379+ (count.min(u16::MAX as usize)) as u16
380+}
381+
313382fn header_height() -> u16 {
314383 let banner_height = ASCII_BANNER.len() as u16;
315384 let welcome_height = 1;
@@ -384,23 +453,15 @@ fn handle_key_event(key: KeyEvent, app: &mut AppState) {
384453 }
385454
386455 match key.code {
387− KeyCode::PageUp => {
388− app.terminal_scroll = app.terminal_scroll.saturating_add(10);
389− }
390− KeyCode::PageDown => {
391− app.terminal_scroll = app.terminal_scroll.saturating_sub(10);
392− }
393− KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
394− app.terminal_scroll = app.terminal_scroll.saturating_add(1);
395− }
396− KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
397− app.terminal_scroll = app.terminal_scroll.saturating_sub(1);
398− }
456+ KeyCode::PageUp => {}
457+ KeyCode::PageDown => {}
458+ KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {}
459+ KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {}
399460 KeyCode::Enter => {
400461 let message = app.take_input_text();
401462 if !message.trim().is_empty() {
402463 app.push_history(format!("> {}", message));
403− app.terminal_scroll = 0;
464+ app.page_scroll = usize::MAX;
404465 }
405466 }
406467 KeyCode::Tab => app.toggle_completions(default_completions()),
@@ -421,10 +482,10 @@ fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) {
421482 use crossterm::event::MouseEventKind;
422483 match event.kind {
423484 MouseEventKind::ScrollUp => {
424− app.terminal_scroll = app.terminal_scroll.saturating_add(3);
485+ app.page_scroll = app.page_scroll.saturating_sub(3);
425486 }
426487 MouseEventKind::ScrollDown => {
427− app.terminal_scroll = app.terminal_scroll.saturating_sub(3);
488+ app.page_scroll = app.page_scroll.saturating_add(3);
428489 }
429490 _ => {}
430491 }
@@ -491,27 +552,49 @@ fn default_completions() -> Vec<String> {
491552}
492553
493554fn render(frame: &mut Frame<'_>, app: &mut AppState) {
494− let area = frame.area();
495− app.input_view_width = app.input_inner_width(area.width);
496− let layout = compute_layout(
497− area,
498− app.input_height_for_width(area.width),
499− app.completions.items().len(),
500− app.completions.is_active(),
501− );
502−
503− render_header(frame, layout.header, app);
504− render_terminal(frame, layout.terminal, app);
505− render_input(frame, layout.input, app);
555+ let viewport = frame.area();
556+ if viewport.width == 0 || viewport.height == 0 {
557+ return;
558+ }
559+
560+ app.input_view_width = app.input_inner_width(viewport.width);
561+ let layout = compute_page_layout(app, viewport.width);
562+ let content_height = layout.total_height.max(1);
563+
564+ let mut buffer = Buffer::empty(Rect::new(0, 0, viewport.width, content_height));
565+ render_header(&mut buffer, layout.header, app);
566+ render_terminal(&mut buffer, layout.terminal, app);
567+ let cursor_pos = render_input(&mut buffer, layout.input, app);
506568
507569 if app.completions.is_active() {
508− render_completions(frame, layout.completions, app);
570+ render_completions(&mut buffer, layout.completions, app);
509571 } else {
510− render_status(frame, layout.status, app);
572+ render_status(&mut buffer, layout.status, app);
573+ }
574+
575+ let max_scroll = content_height.saturating_sub(viewport.height);
576+ app.page_scroll = app.page_scroll.min(max_scroll as usize);
577+ let scroll = app.page_scroll as u16;
578+
579+ let view = frame.buffer_mut();
580+ for y in 0..viewport.height {
581+ let src_y = scroll.saturating_add(y);
582+ if src_y >= content_height {
583+ break;
584+ }
585+ for x in 0..viewport.width {
586+ view[(x, y)] = buffer[(x, src_y)].clone();
587+ }
588+ }
589+
590+ if let Some((x, y)) = cursor_pos {
591+ if y >= scroll && y < scroll.saturating_add(viewport.height) {
592+ frame.set_cursor_position((x, y - scroll));
593+ }
511594 }
512595}
513596
514−fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
597+fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
515598 if area.height == 0 {
516599 return;
517600 }
@@ -530,13 +613,10 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
530613 Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)),
531614 ]);
532615
533− frame.render_widget(Paragraph::new(welcome), header_chunks[0]);
616+ Paragraph::new(welcome).render(header_chunks[0], buffer);
534617
535618 let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line));
536− frame.render_widget(
537− Paragraph::new(Text::from_iter(banner_lines)),
538− header_chunks[1],
539− );
619+ Paragraph::new(Text::from_iter(banner_lines)).render(header_chunks[1], buffer);
540620
541621 let info_block = Block::default().borders(Borders::ALL).title("Session");
542622 let info_lines = vec![
@@ -560,46 +640,44 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
560640 ]),
561641 ];
562642
563− frame.render_widget(
564− Paragraph::new(info_lines).block(info_block),
565− header_chunks[2],
566− );
643+ Paragraph::new(info_lines)
644+ .block(info_block)
645+ .render(header_chunks[2], buffer);
567646}
568647
569−fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
648+fn render_terminal(buffer: &mut Buffer, area: Rect, app: &AppState) {
649+ if area.height == 0 {
650+ return;
651+ }
570652 let lines = app.history.iter().map(|line| Line::from(line.as_str()));
571653 let block = Block::default().borders(Borders::ALL).title("Terminal");
572− let inner = block.inner(area);
573− let inner_height = inner.height.max(1) as usize;
574− let total_lines = app.history.len();
575− let max_top = total_lines.saturating_sub(inner_height);
576− let terminal_scroll = app.terminal_scroll.min(max_top);
577− let scroll_top = max_top.saturating_sub(terminal_scroll);
578654 let paragraph = Paragraph::new(Text::from_iter(lines))
579655 .block(block)
580− .wrap(Wrap { trim: true })
581− .scroll((scroll_top.min(u16::MAX as usize) as u16, 0));
656+ .wrap(Wrap { trim: true });
582657
583− frame.render_widget(paragraph, area);
658+ paragraph.render(area, buffer);
584659}
585660
586−fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
587− frame.render_widget(&app.input, area);
588− if area.height > 0 && area.width > 0 {
589− let cursor = app.input.cursor();
590− let inner = match app.input.block() {
591− Some(block) => block.inner(area),
592− None => area,
593− };
594− let x = inner.x.saturating_add(cursor.1 as u16);
595− let y = inner.y.saturating_add(cursor.0 as u16);
596− if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
597− frame.set_cursor_position((x, y));
598− }
661+fn render_input(buffer: &mut Buffer, area: Rect, app: &mut AppState) -> Option<(u16, u16)> {
662+ if area.height == 0 || area.width == 0 {
663+ return None;
664+ }
665+ app.input.render(area, buffer);
666+ let cursor = app.input.cursor();
667+ let inner = match app.input.block() {
668+ Some(block) => block.inner(area),
669+ None => area,
670+ };
671+ let x = inner.x.saturating_add(cursor.1 as u16);
672+ let y = inner.y.saturating_add(cursor.0 as u16);
673+ if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) {
674+ Some((x, y))
675+ } else {
676+ None
599677 }
600678}
601679
602−fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
680+fn render_completions(buffer: &mut Buffer, area: Rect, app: &mut AppState) {
603681 if area.height == 0 {
604682 return;
605683 }
@@ -620,10 +698,10 @@ fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) {
620698 state.select(Some(app.completions.selected()));
621699 }
622700
623− frame.render_stateful_widget(list, area, &mut state);
701+ StatefulWidget::render(list, area, buffer, &mut state);
624702}
625703
626−fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
704+fn render_status(buffer: &mut Buffer, area: Rect, app: &AppState) {
627705 if area.height == 0 {
628706 return;
629707 }
@@ -638,7 +716,7 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) {
638716 ),
639717 ]));
640718
641− frame.render_widget(status, area);
719+ status.render(area, buffer);
642720}
643721
644722struct TerminalGuard {