mirror of
https://github.com/openai/codex.git
synced 2026-05-02 20:32:04 +03:00
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
248 lines
7.6 KiB
Rust
248 lines
7.6 KiB
Rust
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyModifiers;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::Clear;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::StatefulWidgetRef;
|
|
use ratatui::widgets::Widget;
|
|
use std::cell::RefCell;
|
|
|
|
use crate::render::renderable::Renderable;
|
|
|
|
use super::popup_consts::standard_popup_hint_line;
|
|
|
|
use super::CancellationEvent;
|
|
use super::bottom_pane_view::BottomPaneView;
|
|
use super::textarea::TextArea;
|
|
use super::textarea::TextAreaState;
|
|
|
|
/// Callback invoked when the user submits a custom prompt.
|
|
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
|
|
|
|
/// Minimal multi-line text input view to collect custom review instructions.
|
|
pub(crate) struct CustomPromptView {
|
|
title: String,
|
|
placeholder: String,
|
|
context_label: Option<String>,
|
|
on_submit: PromptSubmitted,
|
|
|
|
// UI state
|
|
textarea: TextArea,
|
|
textarea_state: RefCell<TextAreaState>,
|
|
complete: bool,
|
|
}
|
|
|
|
impl CustomPromptView {
|
|
pub(crate) fn new(
|
|
title: String,
|
|
placeholder: String,
|
|
context_label: Option<String>,
|
|
on_submit: PromptSubmitted,
|
|
) -> Self {
|
|
Self {
|
|
title,
|
|
placeholder,
|
|
context_label,
|
|
on_submit,
|
|
textarea: TextArea::new(),
|
|
textarea_state: RefCell::new(TextAreaState::default()),
|
|
complete: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BottomPaneView for CustomPromptView {
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::Esc, ..
|
|
} => {
|
|
self.on_ctrl_c();
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
modifiers: KeyModifiers::NONE,
|
|
..
|
|
} => {
|
|
let text = self.textarea.text().trim().to_string();
|
|
if !text.is_empty() {
|
|
(self.on_submit)(text);
|
|
self.complete = true;
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
..
|
|
} => {
|
|
self.textarea.input(key_event);
|
|
}
|
|
other => {
|
|
self.textarea.input(other);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
self.complete = true;
|
|
CancellationEvent::Handled
|
|
}
|
|
|
|
fn is_complete(&self) -> bool {
|
|
self.complete
|
|
}
|
|
|
|
fn handle_paste(&mut self, pasted: String) -> bool {
|
|
if pasted.is_empty() {
|
|
return false;
|
|
}
|
|
self.textarea.insert_str(&pasted);
|
|
true
|
|
}
|
|
}
|
|
|
|
impl Renderable for CustomPromptView {
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
|
1u16 + extra_top + self.input_height(width) + 3u16
|
|
}
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.height == 0 || area.width == 0 {
|
|
return;
|
|
}
|
|
|
|
let input_height = self.input_height(area.width);
|
|
|
|
// Title line
|
|
let title_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
|
|
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
|
|
|
|
// Optional context line
|
|
let mut input_y = area.y.saturating_add(1);
|
|
if let Some(context_label) = &self.context_label {
|
|
let context_area = Rect {
|
|
x: area.x,
|
|
y: input_y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
|
|
Paragraph::new(Line::from(spans)).render(context_area, buf);
|
|
input_y = input_y.saturating_add(1);
|
|
}
|
|
|
|
// Input line
|
|
let input_area = Rect {
|
|
x: area.x,
|
|
y: input_y,
|
|
width: area.width,
|
|
height: input_height,
|
|
};
|
|
if input_area.width >= 2 {
|
|
for row in 0..input_area.height {
|
|
Paragraph::new(Line::from(vec![gutter()])).render(
|
|
Rect {
|
|
x: input_area.x,
|
|
y: input_area.y.saturating_add(row),
|
|
width: 2,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
}
|
|
|
|
let text_area_height = input_area.height.saturating_sub(1);
|
|
if text_area_height > 0 {
|
|
if input_area.width > 2 {
|
|
let blank_rect = Rect {
|
|
x: input_area.x.saturating_add(2),
|
|
y: input_area.y,
|
|
width: input_area.width.saturating_sub(2),
|
|
height: 1,
|
|
};
|
|
Clear.render(blank_rect, buf);
|
|
}
|
|
let textarea_rect = Rect {
|
|
x: input_area.x.saturating_add(2),
|
|
y: input_area.y.saturating_add(1),
|
|
width: input_area.width.saturating_sub(2),
|
|
height: text_area_height,
|
|
};
|
|
let mut state = self.textarea_state.borrow_mut();
|
|
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
|
|
if self.textarea.text().is_empty() {
|
|
Paragraph::new(Line::from(self.placeholder.clone().dim()))
|
|
.render(textarea_rect, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
let hint_blank_y = input_area.y.saturating_add(input_height);
|
|
if hint_blank_y < area.y.saturating_add(area.height) {
|
|
let blank_area = Rect {
|
|
x: area.x,
|
|
y: hint_blank_y,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
Clear.render(blank_area, buf);
|
|
}
|
|
|
|
let hint_y = hint_blank_y.saturating_add(1);
|
|
if hint_y < area.y.saturating_add(area.height) {
|
|
Paragraph::new(standard_popup_hint_line()).render(
|
|
Rect {
|
|
x: area.x,
|
|
y: hint_y,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
buf,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
if area.height < 2 || area.width <= 2 {
|
|
return None;
|
|
}
|
|
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
|
if text_area_height == 0 {
|
|
return None;
|
|
}
|
|
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
|
let top_line_count = 1u16 + extra_offset;
|
|
let textarea_rect = Rect {
|
|
x: area.x.saturating_add(2),
|
|
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
|
width: area.width.saturating_sub(2),
|
|
height: text_area_height,
|
|
};
|
|
let state = *self.textarea_state.borrow();
|
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
|
}
|
|
}
|
|
|
|
impl CustomPromptView {
|
|
fn input_height(&self, width: u16) -> u16 {
|
|
let usable_width = width.saturating_sub(2);
|
|
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
|
|
text_height.saturating_add(1).min(9)
|
|
}
|
|
}
|
|
|
|
fn gutter() -> Span<'static> {
|
|
"▌ ".cyan()
|
|
}
|