mirror of
https://github.com/openai/codex.git
synced 2026-05-13 15:52:40 +00:00
Compare commits
1 Commits
pr20384
...
fcoury/enh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7900a092aa |
@@ -4324,6 +4324,7 @@ impl App {
|
||||
let pasted = pasted.replace("\r", "\n");
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
if self.backtrack_render_pending {
|
||||
self.backtrack_render_pending = false;
|
||||
@@ -4555,6 +4556,9 @@ impl App {
|
||||
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::OpenCopyPicker => {
|
||||
self.open_copy_picker(tui);
|
||||
}
|
||||
AppEvent::ResumeSessionByIdOrName(id_or_name) => {
|
||||
match crate::lookup_session_target_with_app_server(
|
||||
app_server,
|
||||
|
||||
@@ -30,6 +30,7 @@ use std::sync::Arc;
|
||||
use crate::app::App;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::copy_target::build_copy_targets;
|
||||
#[cfg(test)]
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
@@ -234,7 +235,26 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Close transcript overlay and restore normal UI.
|
||||
/// Open the semantic copy picker (enters alternate screen and enables mouse capture).
|
||||
pub(crate) fn open_copy_picker(&mut self, tui: &mut tui::Tui) {
|
||||
let targets = build_copy_targets(
|
||||
self.chat_widget.last_agent_markdown_text(),
|
||||
&self.transcript_cells,
|
||||
);
|
||||
if targets.is_empty() {
|
||||
self.chat_widget
|
||||
.add_error_message("No recent content to copy".to_string());
|
||||
tui.frame_requester().schedule_frame();
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tui.enter_alt_screen();
|
||||
let _ = tui.enable_mouse_capture();
|
||||
self.overlay = Some(Overlay::new_copy_picker(targets));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Close the active alternate-screen overlay and restore normal UI.
|
||||
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.leave_alt_screen();
|
||||
let was_backtrack = self.backtrack.overlay_preview_active;
|
||||
@@ -392,8 +412,15 @@ impl App {
|
||||
|
||||
if let Some(overlay) = &mut self.overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
let pending_copy = overlay.take_pending_copy();
|
||||
if overlay.is_done() {
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(target) = pending_copy {
|
||||
self.chat_widget.copy_text_to_clipboard(
|
||||
&target.content,
|
||||
format!("Copied {} to clipboard", target.title),
|
||||
);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ pub(crate) enum AppEvent {
|
||||
/// Open the resume picker inside the running TUI session.
|
||||
OpenResumePicker,
|
||||
|
||||
/// Open the semantic copy picker in the alternate screen.
|
||||
OpenCopyPicker,
|
||||
|
||||
/// Resume a thread by UUID or thread name inside the running TUI session.
|
||||
ResumeSessionByIdOrName(String),
|
||||
|
||||
|
||||
@@ -5258,18 +5258,11 @@ impl ChatWidget {
|
||||
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
|
||||
) {
|
||||
match self.last_agent_markdown.clone() {
|
||||
Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) {
|
||||
Ok(lease) => {
|
||||
self.clipboard_lease = lease;
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
"Copied last message to clipboard".into(),
|
||||
/*hint*/ None,
|
||||
));
|
||||
}
|
||||
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Copy failed: {error}"
|
||||
))),
|
||||
},
|
||||
Some(markdown) if !markdown.is_empty() => self.copy_text_to_clipboard_with(
|
||||
&markdown,
|
||||
"Copied last message to clipboard",
|
||||
copy_fn,
|
||||
),
|
||||
_ => self.add_to_history(history_cell::new_error_event(
|
||||
"No agent response to copy".into(),
|
||||
)),
|
||||
@@ -5277,11 +5270,39 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
|
||||
self.last_agent_markdown.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn copy_text_to_clipboard(&mut self, text: &str, success_message: String) {
|
||||
self.copy_text_to_clipboard_with(
|
||||
text,
|
||||
success_message,
|
||||
crate::clipboard_copy::copy_to_clipboard,
|
||||
);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn copy_text_to_clipboard_with(
|
||||
&mut self,
|
||||
text: &str,
|
||||
success_message: impl Into<String>,
|
||||
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
|
||||
) {
|
||||
match copy_fn(text) {
|
||||
Ok(lease) => {
|
||||
self.clipboard_lease = lease;
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
success_message.into(),
|
||||
/*hint*/ None,
|
||||
));
|
||||
}
|
||||
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Copy failed: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn show_rename_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
|
||||
|
||||
@@ -243,6 +243,9 @@ impl ChatWidget {
|
||||
SlashCommand::Copy => {
|
||||
self.copy_last_agent_markdown();
|
||||
}
|
||||
SlashCommand::CopyPicker => {
|
||||
self.app_event_tx.send(AppEvent::OpenCopyPicker);
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
self.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
|
||||
@@ -325,6 +325,15 @@ async fn slash_copy_reports_when_no_agent_response_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_copy_picker_requests_picker_overlay() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_command(SlashCommand::CopyPicker);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenCopyPicker));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
530
codex-rs/tui/src/copy_picker_overlay.rs
Normal file
530
codex-rs/tui/src/copy_picker_overlay.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
use std::io::Result;
|
||||
|
||||
use crate::copy_target::CopyTarget;
|
||||
use crate::copy_target::CopyTargetKind;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const TARGET_BAR_HEIGHT: u16 = 1;
|
||||
const FOOTER_HEIGHT: u16 = 2;
|
||||
|
||||
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
|
||||
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
|
||||
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
|
||||
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
|
||||
const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k'));
|
||||
const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j'));
|
||||
const KEY_H: KeyBinding = key_hint::plain(KeyCode::Char('h'));
|
||||
const KEY_L: KeyBinding = key_hint::plain(KeyCode::Char('l'));
|
||||
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
|
||||
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
|
||||
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
|
||||
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
|
||||
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
|
||||
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
|
||||
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
|
||||
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CopyPickerAreas {
|
||||
target_bar: Rect,
|
||||
preview: Rect,
|
||||
footer: Rect,
|
||||
}
|
||||
|
||||
pub(crate) struct CopyPickerOverlay {
|
||||
targets: Vec<CopyTarget>,
|
||||
selected: usize,
|
||||
target_scroll_offset: usize,
|
||||
preview_scroll_offset: usize,
|
||||
pending_copy: Option<CopyTarget>,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
impl CopyPickerOverlay {
|
||||
pub(crate) fn new(targets: Vec<CopyTarget>) -> Self {
|
||||
Self {
|
||||
targets,
|
||||
selected: 0,
|
||||
target_scroll_offset: 0,
|
||||
preview_scroll_offset: 0,
|
||||
pending_copy: None,
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
TuiEvent::Mouse(mouse_event) => {
|
||||
self.handle_mouse_event(tui, mouse_event);
|
||||
Ok(())
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
self.render(frame.area(), frame.buffer);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
TuiEvent::Paste(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_copy(&mut self) -> Option<CopyTarget> {
|
||||
self.pending_copy.take()
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: crossterm::event::KeyEvent) {
|
||||
let areas = self.areas(tui.terminal.viewport_area);
|
||||
let line_count = self.preview_lines_for_width(areas.preview.width).len();
|
||||
let page = areas.preview.height.max(1) as usize;
|
||||
match key_event {
|
||||
e if KEY_Q.is_press(e) || KEY_ESC.is_press(e) || KEY_CTRL_C.is_press(e) => {
|
||||
self.is_done = true;
|
||||
}
|
||||
e if KEY_ENTER.is_press(e) => {
|
||||
self.copy_selected();
|
||||
}
|
||||
e if KEY_LEFT.is_press(e) || KEY_H.is_press(e) => {
|
||||
self.select_target(self.selected.saturating_sub(1), areas.target_bar.width);
|
||||
}
|
||||
e if KEY_RIGHT.is_press(e) || KEY_L.is_press(e) => {
|
||||
self.select_target(
|
||||
(self.selected + 1).min(self.targets.len().saturating_sub(1)),
|
||||
areas.target_bar.width,
|
||||
);
|
||||
}
|
||||
e if KEY_UP.is_press(e) || KEY_K.is_press(e) => {
|
||||
self.scroll_preview_up(1);
|
||||
}
|
||||
e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => {
|
||||
self.scroll_preview_down(1, line_count, page);
|
||||
}
|
||||
e if KEY_PAGE_UP.is_press(e) => {
|
||||
self.scroll_preview_up(page);
|
||||
}
|
||||
e if KEY_PAGE_DOWN.is_press(e) => {
|
||||
self.scroll_preview_down(page, line_count, page);
|
||||
}
|
||||
e if KEY_HOME.is_press(e) => {
|
||||
self.preview_scroll_offset = 0;
|
||||
}
|
||||
e if KEY_END.is_press(e) => {
|
||||
self.preview_scroll_offset = max_preview_scroll(line_count, page);
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
|
||||
fn handle_mouse_event(&mut self, tui: &mut tui::Tui, mouse_event: MouseEvent) {
|
||||
let areas = self.areas(tui.terminal.viewport_area);
|
||||
let line_count = self.preview_lines_for_width(areas.preview.width).len();
|
||||
let changed = self.handle_mouse_event_in_area(areas, line_count, mouse_event);
|
||||
if changed {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse_event_in_area(
|
||||
&mut self,
|
||||
areas: CopyPickerAreas,
|
||||
line_count: usize,
|
||||
mouse_event: MouseEvent,
|
||||
) -> bool {
|
||||
let page = areas.preview.height.max(1) as usize;
|
||||
match mouse_event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(index) =
|
||||
self.target_index_at(areas.target_bar, mouse_event.column, mouse_event.row)
|
||||
{
|
||||
self.select_target(index, areas.target_bar.width);
|
||||
return true;
|
||||
}
|
||||
if point_in_rect(areas.preview, mouse_event.column, mouse_event.row) {
|
||||
self.copy_selected();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
self.scroll_preview_up(1);
|
||||
return true;
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
self.scroll_preview_down(1, line_count, page);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn select_target(&mut self, index: usize, target_bar_width: u16) {
|
||||
if self.targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = index.min(self.targets.len() - 1);
|
||||
self.preview_scroll_offset = 0;
|
||||
self.ensure_selected_target_visible(target_bar_width);
|
||||
}
|
||||
|
||||
fn scroll_preview_up(&mut self, amount: usize) {
|
||||
self.preview_scroll_offset = self.preview_scroll_offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
fn scroll_preview_down(&mut self, amount: usize, line_count: usize, page: usize) {
|
||||
self.preview_scroll_offset =
|
||||
(self.preview_scroll_offset + amount).min(max_preview_scroll(line_count, page));
|
||||
}
|
||||
|
||||
fn copy_selected(&mut self) {
|
||||
if let Some(target) = self.targets.get(self.selected) {
|
||||
self.pending_copy = Some(target.clone());
|
||||
self.is_done = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let areas = self.areas(area);
|
||||
self.ensure_selected_target_visible(areas.target_bar.width);
|
||||
self.render_target_bar(areas.target_bar, buf);
|
||||
self.render_preview(areas.preview, buf);
|
||||
self.render_footer(areas.footer, buf);
|
||||
}
|
||||
|
||||
fn render_target_bar(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_style = Style::default().add_modifier(Modifier::REVERSED);
|
||||
let mut spans = Vec::new();
|
||||
let mut width = 0usize;
|
||||
for idx in self.target_scroll_offset..self.targets.len() {
|
||||
let label = self.target_tab_label(idx);
|
||||
let label_width = UnicodeWidthStr::width(label.as_str());
|
||||
let style = if idx == self.selected {
|
||||
selected_style
|
||||
} else {
|
||||
Style::default().dim()
|
||||
};
|
||||
spans.push(Span::styled(label, style));
|
||||
width = width.saturating_add(label_width);
|
||||
if width >= area.width as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Paragraph::new(Line::from(spans)).render(area, buf);
|
||||
}
|
||||
|
||||
fn render_preview(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let lines = self.preview_lines_for_width(area.width);
|
||||
let page = area.height.max(1) as usize;
|
||||
self.preview_scroll_offset = self
|
||||
.preview_scroll_offset
|
||||
.min(max_preview_scroll(lines.len(), page));
|
||||
let end = (self.preview_scroll_offset + page).min(lines.len());
|
||||
let visible = lines[self.preview_scroll_offset..end].to_vec();
|
||||
Paragraph::new(visible).render(area, buf);
|
||||
}
|
||||
|
||||
fn render_footer(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
if area.height == 1 {
|
||||
render_key_hints(
|
||||
area,
|
||||
buf,
|
||||
&[
|
||||
(&[KEY_LEFT, KEY_RIGHT, KEY_H, KEY_L], "target"),
|
||||
(&[KEY_UP, KEY_DOWN, KEY_K, KEY_J], "scroll"),
|
||||
(&[KEY_ENTER], "copy"),
|
||||
(&[KEY_Q, KEY_ESC], "close"),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
render_key_hints(
|
||||
Rect::new(area.x, area.y, area.width, 1),
|
||||
buf,
|
||||
&[
|
||||
(&[KEY_LEFT, KEY_RIGHT, KEY_H, KEY_L], "target"),
|
||||
(&[KEY_UP, KEY_DOWN, KEY_K, KEY_J], "scroll"),
|
||||
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "page"),
|
||||
],
|
||||
);
|
||||
render_key_hints(
|
||||
Rect::new(area.x, area.y + 1, area.width, 1),
|
||||
buf,
|
||||
&[(&[KEY_ENTER], "copy"), (&[KEY_Q, KEY_ESC], "close")],
|
||||
);
|
||||
}
|
||||
|
||||
fn preview_lines_for_width(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let Some(target) = self.targets.get(self.selected) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let options = RtOptions::new(width.max(1) as usize);
|
||||
word_wrap_lines(target.content.split('\n'), options)
|
||||
}
|
||||
|
||||
fn areas(&self, area: Rect) -> CopyPickerAreas {
|
||||
let target_bar_height = area.height.min(TARGET_BAR_HEIGHT);
|
||||
let footer_height = area
|
||||
.height
|
||||
.saturating_sub(target_bar_height)
|
||||
.min(FOOTER_HEIGHT);
|
||||
let preview_height = area
|
||||
.height
|
||||
.saturating_sub(target_bar_height)
|
||||
.saturating_sub(footer_height);
|
||||
CopyPickerAreas {
|
||||
target_bar: Rect::new(area.x, area.y, area.width, target_bar_height),
|
||||
preview: Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(target_bar_height),
|
||||
area.width,
|
||||
preview_height,
|
||||
),
|
||||
footer: Rect::new(
|
||||
area.x,
|
||||
area.bottom().saturating_sub(footer_height),
|
||||
area.width,
|
||||
footer_height,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_selected_target_visible(&mut self, target_bar_width: u16) {
|
||||
if self.targets.is_empty() || target_bar_width == 0 {
|
||||
return;
|
||||
}
|
||||
if self.selected < self.target_scroll_offset {
|
||||
self.target_scroll_offset = self.selected;
|
||||
}
|
||||
while self.target_scroll_offset < self.selected
|
||||
&& !self.selected_target_starts_in_bar(target_bar_width)
|
||||
{
|
||||
self.target_scroll_offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_target_starts_in_bar(&self, target_bar_width: u16) -> bool {
|
||||
let mut used = 0usize;
|
||||
let target_bar_width = target_bar_width as usize;
|
||||
for idx in self.target_scroll_offset..self.targets.len() {
|
||||
if idx == self.selected {
|
||||
return used < target_bar_width;
|
||||
}
|
||||
used = used.saturating_add(self.target_tab_width(idx));
|
||||
if used >= target_bar_width {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn target_index_at(&self, area: Rect, column: u16, row: u16) -> Option<usize> {
|
||||
if area.height == 0
|
||||
|| row != area.y
|
||||
|| column < area.x
|
||||
|| column >= area.right()
|
||||
|| area.width == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut x = area.x;
|
||||
for idx in self.target_scroll_offset..self.targets.len() {
|
||||
let width = self.target_tab_width_u16(idx);
|
||||
let right = x.saturating_add(width);
|
||||
if column >= x && column < right {
|
||||
return Some(idx);
|
||||
}
|
||||
x = right;
|
||||
if x >= area.right() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn target_tab_label(&self, index: usize) -> String {
|
||||
let Some(target) = self.targets.get(index) else {
|
||||
return String::new();
|
||||
};
|
||||
let label = match &target.kind {
|
||||
CopyTargetKind::CodeBlock => target.title.as_str(),
|
||||
kind => kind.label(),
|
||||
};
|
||||
format!(" {} {label} ", index + 1)
|
||||
}
|
||||
|
||||
fn target_tab_width(&self, index: usize) -> usize {
|
||||
UnicodeWidthStr::width(self.target_tab_label(index).as_str())
|
||||
}
|
||||
|
||||
fn target_tab_width_u16(&self, index: usize) -> u16 {
|
||||
self.target_tab_width(index).min(u16::MAX as usize) as u16
|
||||
}
|
||||
}
|
||||
|
||||
fn max_preview_scroll(line_count: usize, page: usize) -> usize {
|
||||
line_count.saturating_sub(page.max(1))
|
||||
}
|
||||
|
||||
fn point_in_rect(area: Rect, column: u16, row: u16) -> bool {
|
||||
column >= area.x && column < area.right() && row >= area.y && row < area.bottom()
|
||||
}
|
||||
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
let mut first = true;
|
||||
for (keys, desc) in pairs {
|
||||
if !first {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
for (i, key) in keys.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push("/".into());
|
||||
}
|
||||
spans.push(Span::from(key));
|
||||
}
|
||||
if !keys.is_empty() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.push(Span::from(desc.to_string()));
|
||||
first = false;
|
||||
}
|
||||
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
use super::*;
|
||||
use crate::copy_target::CopyTargetKind;
|
||||
|
||||
fn target(kind: CopyTargetKind, title: &str, content: &str) -> CopyTarget {
|
||||
CopyTarget::new(kind, title, content)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_picker_snapshot_basic() {
|
||||
let mut overlay = CopyPickerOverlay::new(vec![
|
||||
target(
|
||||
CopyTargetKind::AssistantResponse,
|
||||
"Last response",
|
||||
"first paragraph\n\nsecond paragraph\nthird paragraph",
|
||||
),
|
||||
target(CopyTargetKind::Command, "Command", "rg copy target"),
|
||||
target(CopyTargetKind::Output, "Output", "src/lib.rs\nsrc/app.rs"),
|
||||
]);
|
||||
let mut term = Terminal::new(TestBackend::new(64, 12)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_copies_selected_target() {
|
||||
let mut overlay = CopyPickerOverlay::new(vec![
|
||||
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
|
||||
target(CopyTargetKind::Command, "Command", "rg copy target"),
|
||||
]);
|
||||
overlay.selected = 1;
|
||||
overlay.copy_selected();
|
||||
|
||||
assert_eq!(
|
||||
overlay.take_pending_copy().map(|target| target.content),
|
||||
Some("rg copy target".to_string())
|
||||
);
|
||||
assert!(overlay.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_target_bar_selects_target() {
|
||||
let mut overlay = CopyPickerOverlay::new(vec![
|
||||
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
|
||||
target(CopyTargetKind::Command, "Command", "rg copy target"),
|
||||
]);
|
||||
let areas = overlay.areas(Rect::new(0, 0, 64, 12));
|
||||
let command_column = overlay.target_tab_width_u16(0) + 1;
|
||||
overlay.handle_mouse_event_in_area(
|
||||
areas,
|
||||
/*line_count*/ 1,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: command_column,
|
||||
row: areas.target_bar.y,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(overlay.selected, 1);
|
||||
assert_eq!(overlay.take_pending_copy(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_click_preview_copies_selected_target() {
|
||||
let mut overlay = CopyPickerOverlay::new(vec![
|
||||
target(CopyTargetKind::AssistantResponse, "Last response", "hello"),
|
||||
target(CopyTargetKind::Command, "Command", "rg copy target"),
|
||||
]);
|
||||
let areas = overlay.areas(Rect::new(0, 0, 64, 12));
|
||||
overlay.handle_mouse_event_in_area(
|
||||
areas,
|
||||
/*line_count*/ 1,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: areas.preview.x,
|
||||
row: areas.preview.y,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.take_pending_copy().map(|target| target.content),
|
||||
Some("hello".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
275
codex-rs/tui/src/copy_target.rs
Normal file
275
codex-rs/tui/src/copy_target.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pulldown_cmark::CodeBlockKind;
|
||||
use pulldown_cmark::Event;
|
||||
use pulldown_cmark::Options;
|
||||
use pulldown_cmark::Parser;
|
||||
use pulldown_cmark::Tag;
|
||||
use pulldown_cmark::TagEnd;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
const MAX_PREVIEW_CHARS: usize = 96;
|
||||
const MAX_EXEC_CALLS: usize = 10;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CopyTargetKind {
|
||||
AssistantResponse,
|
||||
CodeBlock,
|
||||
Command,
|
||||
Output,
|
||||
}
|
||||
|
||||
impl CopyTargetKind {
|
||||
pub(crate) fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AssistantResponse => "Response",
|
||||
Self::CodeBlock => "Code",
|
||||
Self::Command => "Command",
|
||||
Self::Output => "Output",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CopyTarget {
|
||||
pub(crate) kind: CopyTargetKind,
|
||||
pub(crate) title: String,
|
||||
pub(crate) preview: String,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
impl CopyTarget {
|
||||
pub(crate) fn new(
|
||||
kind: CopyTargetKind,
|
||||
title: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
let content = content.into();
|
||||
Self {
|
||||
kind,
|
||||
title: title.into(),
|
||||
preview: preview_for(&content),
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CopyTargetGroup {
|
||||
pub(crate) targets: Vec<CopyTarget>,
|
||||
}
|
||||
|
||||
impl CopyTargetGroup {
|
||||
pub(crate) fn new(targets: Vec<CopyTarget>) -> Self {
|
||||
Self { targets }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_copy_targets(
|
||||
last_agent_markdown: Option<&str>,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
) -> Vec<CopyTarget> {
|
||||
let mut targets = Vec::new();
|
||||
|
||||
if let Some(markdown) = last_agent_markdown.filter(|text| !text.is_empty()) {
|
||||
targets.push(CopyTarget::new(
|
||||
CopyTargetKind::AssistantResponse,
|
||||
"Last response",
|
||||
markdown.to_string(),
|
||||
));
|
||||
|
||||
for (idx, block) in code_blocks_from_markdown(markdown).into_iter().enumerate() {
|
||||
let title = match block.language {
|
||||
Some(language) => format!("Code block {} ({language})", idx + 1),
|
||||
None => format!("Code block {}", idx + 1),
|
||||
};
|
||||
targets.push(CopyTarget::new(
|
||||
CopyTargetKind::CodeBlock,
|
||||
title,
|
||||
block.content,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut exec_groups = 0usize;
|
||||
for cell in cells.iter().rev() {
|
||||
for group in cell.copy_target_groups() {
|
||||
if exec_groups >= MAX_EXEC_CALLS {
|
||||
break;
|
||||
}
|
||||
targets.extend(group.targets);
|
||||
exec_groups += 1;
|
||||
}
|
||||
if exec_groups >= MAX_EXEC_CALLS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
pub(crate) fn output_text_for_copy(output: &str) -> String {
|
||||
trim_trailing_line_endings(strip_ansi_escape_sequences(output))
|
||||
}
|
||||
|
||||
pub(crate) fn trim_trailing_line_endings(mut text: String) -> String {
|
||||
while text.ends_with('\n') || text.ends_with('\r') {
|
||||
text.pop();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct CodeBlock {
|
||||
language: Option<String>,
|
||||
content: String,
|
||||
}
|
||||
|
||||
fn code_blocks_from_markdown(markdown: &str) -> Vec<CodeBlock> {
|
||||
let parser = Parser::new_ext(markdown, Options::all());
|
||||
let mut blocks = Vec::new();
|
||||
let mut current: Option<CodeBlock> = None;
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
current = Some(CodeBlock {
|
||||
language: language_from_code_block_kind(kind),
|
||||
content: String::new(),
|
||||
});
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if let Some(block) = &mut current {
|
||||
block.content.push_str(&text);
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
if let Some(mut block) = current.take() {
|
||||
block.content = trim_trailing_line_endings(block.content);
|
||||
if !block.content.is_empty() {
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn language_from_code_block_kind(kind: CodeBlockKind<'_>) -> Option<String> {
|
||||
match kind {
|
||||
CodeBlockKind::Fenced(info) => info
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.filter(|language| !language.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
CodeBlockKind::Indented => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_for(content: &str) -> String {
|
||||
let single_line = content
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
truncate_chars(&single_line, MAX_PREVIEW_CHARS)
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
let mut chars = text.chars();
|
||||
let mut out: String = chars.by_ref().take(max_chars).collect();
|
||||
if chars.next().is_some() {
|
||||
out.push_str("...");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_ansi_escape_sequences(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\u{1b}' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
match chars.peek().copied() {
|
||||
Some('[') => {
|
||||
chars.next();
|
||||
for c in chars.by_ref() {
|
||||
if ('\u{40}'..='\u{7e}').contains(&c) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(']') | Some('P') | Some('_') | Some('^') => {
|
||||
chars.next();
|
||||
let mut saw_esc = false;
|
||||
for c in chars.by_ref() {
|
||||
if c == '\u{7}' || (saw_esc && c == '\\') {
|
||||
break;
|
||||
}
|
||||
saw_esc = c == '\u{1b}';
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
chars.next();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn extracts_fenced_code_blocks_without_fences() {
|
||||
let markdown =
|
||||
"Intro\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\n- keep bullet\n";
|
||||
|
||||
let blocks = code_blocks_from_markdown(markdown);
|
||||
|
||||
assert_eq!(
|
||||
blocks,
|
||||
vec![CodeBlock {
|
||||
language: Some("rust".to_string()),
|
||||
content: "fn main() {\n println!(\"hi\");\n}".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_copy_strips_ansi_and_trailing_line_endings() {
|
||||
assert_eq!(
|
||||
output_text_for_copy("\u{1b}[31merror\u{1b}[0m\nnext\n"),
|
||||
"error\nnext"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_builder_keeps_markdown_bullets_in_response() {
|
||||
let targets = build_copy_targets(Some("- real bullet\n\ntext"), &[]);
|
||||
|
||||
assert_eq!(
|
||||
targets,
|
||||
vec![CopyTarget {
|
||||
kind: CopyTargetKind::AssistantResponse,
|
||||
title: "Last response".to_string(),
|
||||
preview: "- real bullet".to_string(),
|
||||
content: "- real bullet\n\ntext".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt(
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -3,6 +3,10 @@ use std::time::Instant;
|
||||
use super::model::CommandOutput;
|
||||
use super::model::ExecCall;
|
||||
use super::model::ExecCell;
|
||||
use crate::copy_target::CopyTarget;
|
||||
use crate::copy_target::CopyTargetGroup;
|
||||
use crate::copy_target::CopyTargetKind;
|
||||
use crate::copy_target::output_text_for_copy;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
@@ -248,6 +252,33 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn copy_target_groups(&self) -> Vec<CopyTargetGroup> {
|
||||
self.calls
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|call| {
|
||||
let command = strip_bash_lc_and_escape(&call.command);
|
||||
if command.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut targets =
|
||||
vec![CopyTarget::new(CopyTargetKind::Command, "Command", command)];
|
||||
if let Some(output) = call.output.as_ref() {
|
||||
let output_text = output_text_for_copy(&output.aggregated_output);
|
||||
if !output_text.trim().is_empty() {
|
||||
targets.push(CopyTarget::new(
|
||||
CopyTargetKind::Output,
|
||||
"Output",
|
||||
output_text,
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(CopyTargetGroup::new(targets))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
@@ -723,6 +754,38 @@ mod tests {
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_target_groups_use_unwrapped_command_and_plain_output() {
|
||||
let call = ExecCall {
|
||||
call_id: "call-id".to_string(),
|
||||
command: vec![
|
||||
"bash".into(),
|
||||
"-lc".into(),
|
||||
"printf 'hello world\\n'".into(),
|
||||
],
|
||||
parsed: Vec::new(),
|
||||
output: Some(CommandOutput {
|
||||
exit_code: 0,
|
||||
aggregated_output: "\u{1b}[32mhello\u{1b}[0m\n".to_string(),
|
||||
formatted_output: "\u{1b}[32mhello\u{1b}[0m\n".to_string(),
|
||||
}),
|
||||
source: ExecCommandSource::UserShell,
|
||||
start_time: None,
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
};
|
||||
let cell = ExecCell::new(call, /*animations_enabled*/ false);
|
||||
|
||||
let groups = cell.copy_target_groups();
|
||||
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].targets.len(), 2);
|
||||
assert_eq!(groups[0].targets[0].kind, CopyTargetKind::Command);
|
||||
assert_eq!(groups[0].targets[0].content, "printf 'hello world\\n'");
|
||||
assert_eq!(groups[0].targets[1].kind, CopyTargetKind::Output);
|
||||
assert_eq!(groups[0].targets[1].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_shell_output_is_limited_by_screen_lines() {
|
||||
let long_url_like = format!(
|
||||
|
||||
@@ -117,6 +117,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -182,6 +182,14 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns semantic copy target groups exposed by this cell.
|
||||
///
|
||||
/// Grouping is used by the copy picker to cap recent tool calls while keeping
|
||||
/// related targets, such as a command and its output, adjacent.
|
||||
fn copy_target_groups(&self) -> Vec<crate::copy_target::CopyTargetGroup> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
|
||||
@@ -111,6 +111,8 @@ mod clipboard_copy;
|
||||
mod clipboard_paste;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
mod copy_picker_overlay;
|
||||
mod copy_target;
|
||||
pub(crate) mod custom_terminal;
|
||||
pub use custom_terminal::Terminal;
|
||||
mod cwd_prompt;
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(crate) async fn run_model_migration_prompt(
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
let _ = alt.tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
@@ -481,6 +481,7 @@ pub(crate) async fn run_onboarding_app(
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
if !did_full_clear_after_success
|
||||
&& onboarding_screen.steps.iter().any(|step| {
|
||||
|
||||
@@ -19,6 +19,8 @@ use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::copy_picker_overlay::CopyPickerOverlay;
|
||||
use crate::copy_target::CopyTarget;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
@@ -48,6 +50,7 @@ use ratatui::widgets::Wrap;
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
Static(StaticOverlay),
|
||||
CopyPicker(CopyPickerOverlay),
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
@@ -66,10 +69,15 @@ impl Overlay {
|
||||
Self::Static(StaticOverlay::with_renderables(renderables, title))
|
||||
}
|
||||
|
||||
pub(crate) fn new_copy_picker(targets: Vec<CopyTarget>) -> Self {
|
||||
Self::CopyPicker(CopyPickerOverlay::new(targets))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match self {
|
||||
Overlay::Transcript(o) => o.handle_event(tui, event),
|
||||
Overlay::Static(o) => o.handle_event(tui, event),
|
||||
Overlay::CopyPicker(o) => o.handle_event(tui, event),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +85,14 @@ impl Overlay {
|
||||
match self {
|
||||
Overlay::Transcript(o) => o.is_done(),
|
||||
Overlay::Static(o) => o.is_done(),
|
||||
Overlay::CopyPicker(o) => o.is_done(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_pending_copy(&mut self) -> Option<CopyTarget> {
|
||||
match self {
|
||||
Overlay::CopyPicker(o) => o.take_pending_copy(),
|
||||
Overlay::Transcript(_) | Overlay::Static(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum SlashCommand {
|
||||
Agent,
|
||||
// Undo,
|
||||
Copy,
|
||||
CopyPicker,
|
||||
Diff,
|
||||
Mention,
|
||||
Status,
|
||||
@@ -83,6 +84,7 @@ impl SlashCommand {
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Copy => "copy last response as markdown",
|
||||
SlashCommand::CopyPicker => "open semantic copy picker",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
@@ -165,6 +167,7 @@ impl SlashCommand {
|
||||
| SlashCommand::MemoryUpdate => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::CopyPicker
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
@@ -193,7 +196,7 @@ impl SlashCommand {
|
||||
fn is_visible(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
|
||||
SlashCommand::Copy => !cfg!(target_os = "android"),
|
||||
SlashCommand::Copy | SlashCommand::CopyPicker => !cfg!(target_os = "android"),
|
||||
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
|
||||
_ => true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/copy_picker_overlay.rs
|
||||
assertion_line: 465
|
||||
expression: term.backend()
|
||||
---
|
||||
" 1 Response 2 Command 3 Output "
|
||||
"first paragraph "
|
||||
" "
|
||||
"second paragraph "
|
||||
"third paragraph "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ←/→/h/l target ↑/↓/k/j scroll pgup/pgdn page "
|
||||
" enter copy q/esc close "
|
||||
@@ -16,10 +16,13 @@ use crossterm::Command;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableFocusChange;
|
||||
use crossterm::event::DisableMouseCapture;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::EnableFocusChange;
|
||||
use crossterm::event::EnableMouseCapture;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
use crossterm::event::PushKeyboardEnhancementFlags;
|
||||
use crossterm::terminal::EnterAlternateScreen;
|
||||
@@ -273,6 +276,7 @@ fn set_panic_hook() {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
Mouse(MouseEvent),
|
||||
Paste(String),
|
||||
Draw,
|
||||
}
|
||||
@@ -483,6 +487,7 @@ impl Tui {
|
||||
if !self.alt_screen_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = self.disable_mouse_capture();
|
||||
// Disable alternate scroll when leaving alt-screen
|
||||
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
|
||||
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
||||
@@ -493,6 +498,16 @@ impl Tui {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn enable_mouse_capture(&mut self) -> Result<()> {
|
||||
execute!(self.terminal.backend_mut(), EnableMouseCapture)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disable_mouse_capture(&mut self) -> Result<()> {
|
||||
execute!(self.terminal.backend_mut(), DisableMouseCapture)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.frame_requester().schedule_frame();
|
||||
|
||||
@@ -172,11 +172,11 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
|
||||
/// Poll the shared crossterm stream for the next mapped `TuiEvent`.
|
||||
///
|
||||
/// This skips events we don't use (mouse events, etc.) and keeps polling until it yields
|
||||
/// This skips events we don't use and keeps polling until it yields
|
||||
/// a mapped event, hits `Pending`, or sees EOF/error. When the broker is paused, it drops
|
||||
/// the underlying stream and returns `Pending` to fully release stdin.
|
||||
pub fn poll_crossterm_event(&mut self, cx: &mut Context<'_>) -> Poll<Option<TuiEvent>> {
|
||||
// Some crossterm events map to None (e.g. FocusLost, mouse); loop so we keep polling
|
||||
// Some crossterm events map to None (e.g. FocusLost); loop so we keep polling
|
||||
// until we return a mapped event, hit Pending, or see EOF/error.
|
||||
loop {
|
||||
let poll_result = {
|
||||
@@ -233,7 +233,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.).
|
||||
/// Map a crossterm event to a [`TuiEvent`], skipping events we don't use.
|
||||
fn map_crossterm_event(&mut self, event: Event) -> Option<TuiEvent> {
|
||||
match event {
|
||||
Event::Key(key_event) => {
|
||||
@@ -244,6 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
}
|
||||
Some(TuiEvent::Key(key_event))
|
||||
}
|
||||
Event::Mouse(mouse_event) => Some(TuiEvent::Mouse(mouse_event)),
|
||||
Event::Resize(_, _) => Some(TuiEvent::Draw),
|
||||
Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)),
|
||||
Event::FocusGained => {
|
||||
@@ -255,7 +256,6 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
|
||||
self.terminal_focused.store(false, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn mouse_event_maps_to_tui_event() {
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup();
|
||||
let mut stream = make_stream(broker, draw_rx, terminal_focused);
|
||||
let expected_mouse = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 7,
|
||||
row: 3,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
|
||||
handle.send(Ok(Event::Mouse(expected_mouse)));
|
||||
|
||||
match stream.next().await.unwrap() {
|
||||
TuiEvent::Mouse(mouse) => assert_eq!(mouse, expected_mouse),
|
||||
other => panic!("expected mouse event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn draw_and_key_events_yield_both() {
|
||||
let (broker, handle, draw_tx, draw_rx, terminal_focused) = setup();
|
||||
|
||||
@@ -57,6 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed(
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Mouse(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
|
||||
Reference in New Issue
Block a user