mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
This is a somewhat roundabout way to fix the issue that pressing ^Z would put the shell prompt in the wrong place (overwriting some of the status area below the composer). While I'm at it, clean up the suspend logic and fix some suspend-while-in-alt-screen behavior too.
224 lines
7.7 KiB
Rust
224 lines
7.7 KiB
Rust
use std::io::Result;
|
|
|
|
use crate::insert_history;
|
|
use crate::tui;
|
|
use crate::tui::TuiEvent;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Color;
|
|
use ratatui::style::Style;
|
|
use ratatui::style::Styled;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
pub(crate) struct TranscriptApp {
|
|
pub(crate) transcript_lines: Vec<Line<'static>>,
|
|
pub(crate) scroll_offset: usize,
|
|
pub(crate) is_done: bool,
|
|
title: String,
|
|
}
|
|
|
|
impl TranscriptApp {
|
|
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
|
|
Self {
|
|
transcript_lines,
|
|
scroll_offset: 0,
|
|
is_done: false,
|
|
title: "T R A N S C R I P T".to_string(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_title(transcript_lines: Vec<Line<'static>>, title: String) -> Self {
|
|
Self {
|
|
transcript_lines,
|
|
scroll_offset: 0,
|
|
is_done: false,
|
|
title,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
|
match event {
|
|
TuiEvent::Key(key_event) => self.handle_key_event(tui, key_event),
|
|
TuiEvent::Draw => {
|
|
tui.draw(u16::MAX, |frame| {
|
|
self.render(frame.area(), frame.buffer);
|
|
})?;
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
|
self.transcript_lines.extend(lines);
|
|
}
|
|
|
|
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
|
match key_event {
|
|
// Ctrl+Z is handled at the App level when transcript overlay is active
|
|
KeyEvent {
|
|
code: KeyCode::Char('q'),
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
}
|
|
| KeyEvent {
|
|
code: KeyCode::Char('t'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
}
|
|
| KeyEvent {
|
|
code: KeyCode::Char('c'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
self.is_done = true;
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Up,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Down,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::PageUp,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
let area = self.scroll_area(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::PageDown,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
let area = self.scroll_area(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Home,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.scroll_offset = 0;
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::End,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.scroll_offset = usize::MAX;
|
|
}
|
|
_ => {
|
|
return;
|
|
}
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
fn scroll_area(&self, area: Rect) -> Rect {
|
|
let mut area = area;
|
|
// Reserve 1 line for the header and 4 lines for the bottom status section. This matches the chat composer.
|
|
area.y = area.y.saturating_add(1);
|
|
area.height = area.height.saturating_sub(5);
|
|
area
|
|
}
|
|
|
|
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
|
Span::from("/ ".repeat(area.width as usize / 2))
|
|
.dim()
|
|
.render_ref(area, buf);
|
|
let header = format!("/ {}", self.title);
|
|
Span::from(header).dim().render_ref(area, buf);
|
|
|
|
// Main content area (excludes header and bottom status section)
|
|
let content_area = self.scroll_area(area);
|
|
let wrapped = insert_history::word_wrap_lines(&self.transcript_lines, content_area.width);
|
|
|
|
// Clamp scroll offset to valid range
|
|
self.scroll_offset = self
|
|
.scroll_offset
|
|
.min(wrapped.len().saturating_sub(content_area.height as usize));
|
|
let start = self.scroll_offset;
|
|
let end = (start + content_area.height as usize).min(wrapped.len());
|
|
let page = &wrapped[start..end];
|
|
Paragraph::new(page.to_vec()).render_ref(content_area, buf);
|
|
|
|
// Fill remaining visible lines (if any) with a leading '~' in the first column.
|
|
let visible = (end - start) as u16;
|
|
if content_area.height > visible {
|
|
let extra = content_area.height - visible;
|
|
for i in 0..extra {
|
|
let y = content_area.y.saturating_add(visible + i);
|
|
Span::from("~")
|
|
.dim()
|
|
.render_ref(Rect::new(content_area.x, y, 1, 1), buf);
|
|
}
|
|
}
|
|
|
|
// Bottom status section (4 lines): separator with % scrolled, then key hints (styled like chat composer)
|
|
let sep_y = content_area.bottom();
|
|
let sep_rect = Rect::new(area.x, sep_y, area.width, 1);
|
|
let hints_rect = Rect::new(area.x, sep_y + 1, area.width, 2);
|
|
|
|
// Separator line (dim)
|
|
Span::from("─".repeat(sep_rect.width as usize))
|
|
.dim()
|
|
.render_ref(sep_rect, buf);
|
|
|
|
// Scroll percentage (0-100%) aligned near the right edge
|
|
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
|
|
let percent: u8 = if max_scroll == 0 {
|
|
100
|
|
} else {
|
|
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
|
|
as u8
|
|
};
|
|
let pct_text = format!(" {percent}% ");
|
|
let pct_w = pct_text.chars().count() as u16;
|
|
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
|
|
Span::from(pct_text)
|
|
.dim()
|
|
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
|
|
|
let key_hint_style = Style::default().fg(Color::Cyan);
|
|
|
|
let hints1 = vec![
|
|
" ".into(),
|
|
"↑".set_style(key_hint_style),
|
|
"/".into(),
|
|
"↓".set_style(key_hint_style),
|
|
" scroll ".into(),
|
|
"PgUp".set_style(key_hint_style),
|
|
"/".into(),
|
|
"PgDn".set_style(key_hint_style),
|
|
" page ".into(),
|
|
"Home".set_style(key_hint_style),
|
|
"/".into(),
|
|
"End".set_style(key_hint_style),
|
|
" jump".into(),
|
|
];
|
|
|
|
let hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()];
|
|
Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()])
|
|
.render_ref(hints_rect, buf);
|
|
}
|
|
}
|