mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Use the shared text editor for command-center task renaming (#50216)
## What changed Replace the rename field with a single-line `TextArea` that honors configured editor bindings and the composer's Vim setting. Start renaming in insert mode and support Vim Escape recovery on legacy terminals. Strip carriage returns and newlines from titles and pasted text, keep Vim linewise pastes inline, and scroll horizontally at grapheme boundaries to keep the cursor visible. ## Testing Add single-line editor coverage for newline filtering, Vim commands, repeat edits, and inline yanks. Extend command-center tests to cover Vim renaming across status-filter changes and cursor placement in narrow layouts. GitOrigin-RevId: 8d320d6b6de9f77e18292169418383929ca6ceda
This commit is contained in:
committed by
copyberry
parent
d64360f628
commit
37f53199a6
@@ -1,9 +1,55 @@
|
||||
//! Tab filters and shortcut help yield to configured task and list bindings.
|
||||
//! Shared rename editing and task shortcuts respect configured editor and list bindings.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyEventKind;
|
||||
|
||||
impl AgentsOverviewView {
|
||||
pub(in crate::app::agents_overview_view) fn rename_key(&mut self, key: KeyEvent) -> bool {
|
||||
let mut state = self.state();
|
||||
if state.rename_target.is_none() {
|
||||
return false;
|
||||
}
|
||||
let input = &mut state.input;
|
||||
let editing = input.should_handle_vim_insert_escape(key) || input.is_vim_operator_pending();
|
||||
if !editing {
|
||||
match self.keymap.action_for(key) {
|
||||
Some(ListAction::Cancel) => {
|
||||
drop(state);
|
||||
self.on_ctrl_c();
|
||||
return true;
|
||||
}
|
||||
Some(ListAction::Accept) if !is_plain_text_key_event(key) => {
|
||||
let online = state.connection_notice.is_none() && !state.creating_worktree;
|
||||
drop(state);
|
||||
if online {
|
||||
self.activate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let editor_binding = crate::keymap::keymap_action_ids()
|
||||
.filter(|action| action.context == input.keymap_context())
|
||||
.any(|action| {
|
||||
crate::keymap::bindings_for_action(
|
||||
&self.editor_keymap,
|
||||
action.context.config_name(),
|
||||
action.action,
|
||||
)
|
||||
.is_some_and(|bindings| bindings.is_pressed(key))
|
||||
});
|
||||
if editing
|
||||
|| is_plain_text_key_event(key)
|
||||
|| editor_binding
|
||||
|| !self.center_shortcut_keys.is_pressed(key)
|
||||
{
|
||||
input.input(key);
|
||||
return !crate::keymap::is_dispatch_token_event(key);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(in crate::app::agents_overview_view) fn command_center_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Status tabs, task rows and read-only details share a simple wide/narrow layout.
|
||||
//! Status tabs, task rows, the rename editor and details share a wide/narrow layout.
|
||||
|
||||
use super::hints::hint_line;
|
||||
use super::*;
|
||||
use crate::bottom_pane::render_filled_tab_bar;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub(super) fn row(area: Rect, offset: u16, height: u16) -> Rect {
|
||||
@@ -87,20 +89,28 @@ impl Renderable for AgentsOverviewView {
|
||||
24
|
||||
}
|
||||
|
||||
fn cursor_style(&self, _area: Rect) -> SetCursorStyle {
|
||||
let state = self.state();
|
||||
if state.rename_target.is_some() && state.input.uses_vim_insert_cursor() {
|
||||
SetCursorStyle::SteadyBar
|
||||
} else {
|
||||
SetCursorStyle::DefaultUserShape
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let layout = self.center_layout(area);
|
||||
let state = self.state();
|
||||
if state.editing_metadata() && !layout.search.is_empty() {
|
||||
let input = if state.rename_target.is_some() {
|
||||
&state.input
|
||||
} else {
|
||||
&state.search
|
||||
};
|
||||
let prefix = if state.rename_target.is_some() {
|
||||
"Rename › "
|
||||
} else {
|
||||
"Search › "
|
||||
};
|
||||
if state.rename_target.is_some() {
|
||||
let mut area = layout.search;
|
||||
let prefix_width = 9.min(area.width);
|
||||
area.x += prefix_width;
|
||||
area.width -= prefix_width;
|
||||
return state.input.cursor_pos(area);
|
||||
}
|
||||
let input = &state.search;
|
||||
let prefix = "Search › ";
|
||||
let available = usize::from(layout.search.width).saturating_sub(prefix.width() + 1);
|
||||
return Some((
|
||||
layout.search.x
|
||||
@@ -178,21 +188,26 @@ impl Renderable for AgentsOverviewView {
|
||||
buf,
|
||||
);
|
||||
if state.editing_metadata() {
|
||||
let (label, input) = if state.rename_target.is_some() {
|
||||
("Rename › ", &state.input)
|
||||
} else {
|
||||
("Search › ", &state.search)
|
||||
};
|
||||
let available = usize::from(layout.search.width).saturating_sub(label.width() + 1);
|
||||
buf.set_style(layout.search, crate::bottom_pane::active_tab_style());
|
||||
line(
|
||||
vec![
|
||||
label.cyan().bold(),
|
||||
visible_suffix(input, available).to_owned().into(),
|
||||
],
|
||||
layout.search,
|
||||
buf,
|
||||
);
|
||||
if state.rename_target.is_some() {
|
||||
line("Rename › ".cyan().bold(), layout.search, buf);
|
||||
let mut area = layout.search;
|
||||
let prefix_width = 9.min(area.width);
|
||||
area.x += prefix_width;
|
||||
area.width -= prefix_width;
|
||||
WidgetRef::render_ref(&&state.input, area, buf);
|
||||
} else {
|
||||
let label = "Search › ";
|
||||
let available = usize::from(layout.search.width).saturating_sub(label.width() + 1);
|
||||
line(
|
||||
vec![
|
||||
label.cyan().bold(),
|
||||
visible_suffix(&state.search, available).to_owned().into(),
|
||||
],
|
||||
layout.search,
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
let notice = state
|
||||
.connection_notice
|
||||
|
||||
@@ -199,6 +199,12 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() {
|
||||
Some((36, 3))
|
||||
);
|
||||
if key == 'r' {
|
||||
let cursor = view
|
||||
.cursor_pos(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 18,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(cursor.0 < 24);
|
||||
insta::assert_snapshot!("live_center_narrow_rename", rendered);
|
||||
}
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
@@ -208,6 +214,7 @@ async fn live_center_metadata_clips_at_grapheme_boundaries() {
|
||||
#[tokio::test]
|
||||
async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = AppEventSender::new(tx);
|
||||
let id = ThreadId::new();
|
||||
@@ -216,7 +223,7 @@ async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result
|
||||
overview_thread(
|
||||
id,
|
||||
/*parent_thread_id*/ None,
|
||||
"Target",
|
||||
"Tar\nget",
|
||||
ThreadStatus::Idle,
|
||||
),
|
||||
overview_thread(
|
||||
@@ -236,10 +243,32 @@ async fn live_center_rename_retains_target_when_status_leaves_filter() -> Result
|
||||
}
|
||||
}
|
||||
view.handle_key_event(KeyCode::Char('r').into());
|
||||
assert_eq!(
|
||||
view.cursor_style(Rect::default()),
|
||||
crossterm::cursor::SetCursorStyle::SteadyBar
|
||||
);
|
||||
threads[0].status = ThreadStatus::SystemError;
|
||||
view = app.agents_overview_view(threads.clone(), Some(id));
|
||||
if key == KeyCode::Enter {
|
||||
for edit in [
|
||||
KeyCode::Esc,
|
||||
KeyCode::Char('0'),
|
||||
KeyCode::Char('c'),
|
||||
KeyCode::Char('w'),
|
||||
] {
|
||||
view.handle_key_event(edit.into());
|
||||
}
|
||||
view.handle_paste("Target".into());
|
||||
}
|
||||
view.handle_key_event(KeyCode::Char('!').into());
|
||||
view.handle_key_event(key.into());
|
||||
if key == KeyCode::Esc {
|
||||
assert_eq!(
|
||||
view.cursor_style(Rect::default()),
|
||||
crossterm::cursor::SetCursorStyle::DefaultUserShape
|
||||
);
|
||||
view.handle_key_event(KeyCode::Esc.into());
|
||||
}
|
||||
assert_eq!(view.rows[view.selected_index().unwrap()].thread_id, other);
|
||||
if key == KeyCode::Enter {
|
||||
let rename = rx.try_recv().unwrap();
|
||||
|
||||
@@ -294,12 +294,12 @@ impl App {
|
||||
self.chat_widget.add_info_message(
|
||||
format!(
|
||||
"The rename target disappeared. Unsubmitted title: {}",
|
||||
state.input
|
||||
state.input.text()
|
||||
),
|
||||
/*hint*/ None,
|
||||
);
|
||||
state.rename_target = None;
|
||||
state.input.clear();
|
||||
state.input.set_text_clearing_elements("");
|
||||
}
|
||||
self.chat_widget
|
||||
.replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view));
|
||||
@@ -364,6 +364,11 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
self.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.vim_enabled = self.chat_widget.composer_is_vim_enabled();
|
||||
AgentsOverviewView::new(
|
||||
rows,
|
||||
selected_thread_id,
|
||||
|
||||
@@ -275,7 +275,7 @@ async fn hiding_rename_target_does_not_transfer_draft_to_neighbor() -> Result<()
|
||||
{
|
||||
let state = app.agents_overview.view_state.lock().unwrap();
|
||||
assert_eq!(
|
||||
(state.rename_target.is_some(), state.input.as_str()),
|
||||
(state.rename_target.is_some(), state.input.text()),
|
||||
(false, "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::TextArea;
|
||||
use crate::bottom_pane::ViewCompletion;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::ShortcutHint;
|
||||
@@ -136,7 +137,8 @@ pub(super) struct AgentsOverviewViewState {
|
||||
page_height: usize,
|
||||
status_filter: usize,
|
||||
help: bool,
|
||||
pub(super) input: String,
|
||||
pub(super) input: TextArea,
|
||||
pub(super) vim_enabled: bool,
|
||||
pub(super) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
pub(super) creating_worktree: bool,
|
||||
pub(super) refresh_failed: bool,
|
||||
@@ -154,6 +156,16 @@ pub(super) struct AgentsOverviewViewState {
|
||||
}
|
||||
|
||||
impl AgentsOverviewViewState {
|
||||
pub(super) fn set_rename_input(&mut self, text: &str, keymap: &RuntimeKeymap) {
|
||||
let mut input = TextArea::new_single_line();
|
||||
input.set_keymap_bindings(keymap);
|
||||
input.set_vim_enabled(self.vim_enabled);
|
||||
input.set_text_clearing_elements(text);
|
||||
input.set_cursor(input.text().len());
|
||||
input.enter_vim_insert_mode();
|
||||
self.input = input;
|
||||
}
|
||||
|
||||
pub(super) fn editing_metadata(&self) -> bool {
|
||||
self.searching || self.rename_target.is_some()
|
||||
}
|
||||
@@ -170,6 +182,7 @@ pub(super) struct AgentsOverviewView {
|
||||
agents_keymap: AgentsKeymap,
|
||||
center_shortcut_keys: Vec<crate::key_hint::KeyBinding>,
|
||||
worktrees_enabled: bool,
|
||||
editor_keymap: RuntimeKeymap,
|
||||
}
|
||||
|
||||
impl AgentsOverviewView {
|
||||
@@ -214,7 +227,19 @@ impl AgentsOverviewView {
|
||||
.then_some(binding.chord.prefix)
|
||||
}))
|
||||
.collect();
|
||||
{
|
||||
let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let vim_enabled = state.vim_enabled;
|
||||
if state.rename_target.is_some() {
|
||||
state.input.set_keymap_bindings(&keymap);
|
||||
if state.input.is_vim_enabled() != vim_enabled {
|
||||
state.input.set_vim_enabled(vim_enabled);
|
||||
state.input.enter_vim_insert_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut view = Self {
|
||||
editor_keymap: keymap.clone(),
|
||||
use_theme_colors,
|
||||
rows,
|
||||
project_groups,
|
||||
@@ -324,7 +349,7 @@ impl AgentsOverviewView {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let input = self.state().input.clone();
|
||||
let input = self.state().input.text().to_owned();
|
||||
if self.state().rename_target.is_some() && !input.trim().is_empty() {
|
||||
if let Some(row) = self.selected_row() {
|
||||
self.app_event_tx
|
||||
@@ -334,7 +359,7 @@ impl AgentsOverviewView {
|
||||
});
|
||||
}
|
||||
self.state().rename_target = None;
|
||||
self.state().input.clear();
|
||||
self.state().input.set_text_clearing_elements("");
|
||||
self.reconcile_command_center_selection();
|
||||
} else if let Some(row) = self
|
||||
.selected_row()
|
||||
@@ -355,11 +380,9 @@ impl AgentsOverviewView {
|
||||
fn edit_input(&mut self, edit: impl FnOnce(&mut String)) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let searching = state.searching;
|
||||
edit(if searching {
|
||||
&mut state.search
|
||||
} else {
|
||||
&mut state.input
|
||||
});
|
||||
if searching {
|
||||
edit(&mut state.search);
|
||||
}
|
||||
drop(state);
|
||||
if searching {
|
||||
self.selected = self
|
||||
@@ -480,7 +503,19 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
}
|
||||
|
||||
fn keymap_contexts(&self) -> KeymapContextSet {
|
||||
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
||||
let state = self.state();
|
||||
if state.rename_target.is_some() {
|
||||
let contexts = KeymapContextSet::new(state.input.keymap_context());
|
||||
if state.input.is_vim_operator_pending() {
|
||||
contexts
|
||||
} else {
|
||||
contexts
|
||||
.with(KeymapContext::List)
|
||||
.with(KeymapContext::Agents)
|
||||
}
|
||||
} else {
|
||||
KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Agents)
|
||||
}
|
||||
}
|
||||
|
||||
fn completion(&self) -> Option<ViewCompletion> {
|
||||
@@ -501,7 +536,7 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
state.searching = false;
|
||||
state.rename_target = None;
|
||||
state.search.clear();
|
||||
state.input.clear();
|
||||
state.input.set_text_clearing_elements("");
|
||||
drop(state);
|
||||
self.reconcile_command_center_selection();
|
||||
return CancellationEvent::Handled;
|
||||
@@ -510,6 +545,12 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
if self.state().rename_target.is_some() {
|
||||
self.state()
|
||||
.input
|
||||
.insert_str(&crate::history_cell::sanitize_user_text(pasted.into()));
|
||||
return true;
|
||||
}
|
||||
if self.state().editing_metadata() {
|
||||
return self.edit_input(|input| {
|
||||
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
|
||||
@@ -527,7 +568,7 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
if key.kind == crossterm::event::KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
if self.command_center_key(key) {
|
||||
if self.rename_key(key) || self.command_center_key(key) {
|
||||
return;
|
||||
}
|
||||
if key.code == KeyCode::Backspace
|
||||
@@ -611,8 +652,11 @@ impl BottomPaneView for AgentsOverviewView {
|
||||
if self.agents_keymap.rename.is_pressed(key) {
|
||||
if let Some(row) = self.selected_row() {
|
||||
let mut state = self.state();
|
||||
if state.input.is_empty() {
|
||||
state.input = row.thread.name.clone().unwrap_or_default();
|
||||
if state.rename_target.is_none() {
|
||||
state.set_rename_input(
|
||||
row.thread.name.as_deref().unwrap_or_default(),
|
||||
&self.editor_keymap,
|
||||
);
|
||||
state.search.clear();
|
||||
state.searching = false;
|
||||
state.rename_target = Some(row.thread_id);
|
||||
|
||||
@@ -2810,7 +2810,7 @@ impl App {
|
||||
}
|
||||
Err(error) => {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.input = name;
|
||||
state.set_rename_input(&name, &self.keymap);
|
||||
state.rename_target = Some(thread_id);
|
||||
}
|
||||
self.repaint_agents_overview();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Keyboard input, external editor, and status-line dispatch for the TUI app.
|
||||
//!
|
||||
//! This module owns global key bindings that sit above ChatWidget, including transcript overlay
|
||||
//! entry, Ctrl-L clear, external editor launch, and agent navigation shortcuts.
|
||||
//! entry, Ctrl-L clear, external editor launch, agent navigation shortcuts, and legacy Vim
|
||||
//! Escape recovery for the active composer or command-center rename editor.
|
||||
|
||||
use super::*;
|
||||
use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE;
|
||||
@@ -11,25 +12,40 @@ use crate::keymap::keymap_action_ids;
|
||||
impl App {
|
||||
pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
|
||||
let active_contexts = self.active_keymap_contexts();
|
||||
let rename_active = self
|
||||
.chat_widget
|
||||
.selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID)
|
||||
.is_some();
|
||||
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||||
let insert_escape = if rename_active {
|
||||
let state = self
|
||||
.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.rename_target.is_some() && state.input.should_handle_vim_insert_escape(escape)
|
||||
} else {
|
||||
self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.chat_widget.should_handle_vim_insert_escape(escape)
|
||||
};
|
||||
// Legacy terminals encode Alt+character and Escape+character identically. Active
|
||||
// bindings and either stroke of a chord win; inactive bindings do not consume input.
|
||||
cfg!(unix)
|
||||
&& !self.enhanced_keys_supported
|
||||
&& self.overlay.is_none()
|
||||
&& !self.transcript_view.is_search_editing()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& insert_escape
|
||||
&& matches!(key_event.code, KeyCode::Char(_))
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& (key_event.modifiers == KeyModifiers::ALT
|
||||
|| key_event.modifiers == (KeyModifiers::ALT | KeyModifiers::SHIFT))
|
||||
&& self
|
||||
.chat_widget
|
||||
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
// ChatWidget's fixed image-paste shortcut is not in the configurable keymap.
|
||||
&& !(key_event.kind == KeyEventKind::Press
|
||||
&& !(!rename_active
|
||||
&& key_event.kind == KeyEventKind::Press
|
||||
&& matches!(key_event.code, KeyCode::Char('v' | 'V')))
|
||||
// Empty-draft agent navigation also has fixed legacy-terminal fallbacks.
|
||||
&& !(self.chat_widget.composer_text_with_pending().is_empty()
|
||||
&& !(!rename_active
|
||||
&& self.chat_widget.composer_text_with_pending().is_empty()
|
||||
&& (previous_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true)
|
||||
|| next_agent_shortcut_matches(key_event, /*allow_word_motion_fallback*/ true)))
|
||||
&& !keymap_action_ids()
|
||||
@@ -111,12 +127,14 @@ impl App {
|
||||
}
|
||||
if !was_pending
|
||||
&& contexts.contains(crate::keymap::KeymapContext::Agents)
|
||||
&& self
|
||||
.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.editing_metadata()
|
||||
&& {
|
||||
let state = self
|
||||
.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.editing_metadata() && state.rename_target.is_none()
|
||||
}
|
||||
&& crate::key_hint::is_plain_text_key_event(key_event)
|
||||
{
|
||||
return Some(key_event);
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app::reconnect::reconnect;
|
||||
use crate::app_event::AgentsOverviewThreadRefresh;
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_server_session::ThreadParamsMode;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -135,7 +136,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
app.agents_overview.initialized = overview_initialized;
|
||||
// This task was unarchived elsewhere while disconnected.
|
||||
app.agents_overview.removed_threads.insert(added);
|
||||
let view = app.agents_overview_view(
|
||||
let mut view = app.agents_overview_view(
|
||||
stale.clone(),
|
||||
Some(if previous_thread.is_some() {
|
||||
vanished
|
||||
@@ -143,16 +144,20 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
selected
|
||||
}),
|
||||
);
|
||||
view.handle_key_event(KeyCode::Char('r').into());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
view.handle_paste("Keep this task draft".into());
|
||||
app.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into();
|
||||
app.agents_overview.view_state.lock().unwrap().rename_target =
|
||||
Some(if previous_thread.is_some() {
|
||||
vanished
|
||||
} else {
|
||||
selected
|
||||
});
|
||||
let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone();
|
||||
let draft = |app: &App| {
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.input
|
||||
.text()
|
||||
.to_string()
|
||||
};
|
||||
let stale_request = Uuid::new_v4();
|
||||
app.agents_overview.request_id = Some(stale_request);
|
||||
app.agents_overview.refresh_pending = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The textarea owns editable composer text, placeholder elements, cursor/wrap state, and a
|
||||
//! single-entry kill buffer.
|
||||
//! single-entry kill buffer. Single-line inputs ignore line breaks and scroll horizontally.
|
||||
//!
|
||||
//! Whole-buffer replacement APIs intentionally rebuild only the visible draft state. They clear
|
||||
//! element ranges and derived cursor/wrapping caches, but they keep the kill buffer intact so a
|
||||
@@ -55,6 +55,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
mod editing;
|
||||
mod hyperlinks;
|
||||
mod mouse;
|
||||
mod single_line;
|
||||
mod vim;
|
||||
mod vim_commands;
|
||||
mod vim_search;
|
||||
@@ -141,6 +142,7 @@ pub(crate) struct TextElementSnapshot {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TextArea {
|
||||
text: String,
|
||||
single_line: bool,
|
||||
cursor_pos: usize,
|
||||
mouse_selection: Option<mouse::MouseSelection>,
|
||||
last_click: Option<(std::time::Instant, u16, u16, u8)>,
|
||||
@@ -199,11 +201,18 @@ pub(crate) struct KillBufferSnapshot {
|
||||
kind: KillBufferKind,
|
||||
}
|
||||
|
||||
impl Default for TextArea {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new() -> Self {
|
||||
let defaults = RuntimeKeymap::defaults();
|
||||
Self {
|
||||
text: String::new(),
|
||||
single_line: false,
|
||||
cursor_pos: 0,
|
||||
mouse_selection: None,
|
||||
last_click: None,
|
||||
@@ -262,6 +271,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
|
||||
let filtered = self.filter_line_breaks(text);
|
||||
let text = filtered.as_ref();
|
||||
self.mouse_selection = None;
|
||||
// Stage 1: replace the raw text and keep the cursor in a safe byte range.
|
||||
self.text = text.to_string();
|
||||
@@ -455,6 +466,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn insert_str_at(&mut self, pos: usize, text: &str) {
|
||||
let filtered = self.filter_line_breaks(text);
|
||||
let text = filtered.as_ref();
|
||||
self.mouse_selection = None;
|
||||
self.clear_vim_replace_recovery();
|
||||
let pos = self.clamp_pos_for_insertion(pos);
|
||||
@@ -479,6 +492,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
fn replace_range_raw(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||
let filtered = self.filter_line_breaks(text);
|
||||
let text = filtered.as_ref();
|
||||
self.mouse_selection = None;
|
||||
assert!(range.start <= range.end);
|
||||
let start = range.start.clamp(0, self.text.len());
|
||||
@@ -524,6 +539,9 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.single_line {
|
||||
return 1;
|
||||
}
|
||||
self.wrapped_lines(width).len() as u16
|
||||
}
|
||||
|
||||
@@ -540,6 +558,10 @@ impl TextArea {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.single_line {
|
||||
let (_, col) = self.single_line_viewport(area.width);
|
||||
return Some((area.x + col, area.y));
|
||||
}
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let effective_scroll = self.effective_scroll(area, &lines, state);
|
||||
let (i, col) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor_pos)?;
|
||||
@@ -824,11 +846,15 @@ impl TextArea {
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.open_line_below.is_pressed(event) {
|
||||
self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenBelow));
|
||||
if !self.single_line {
|
||||
self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenBelow));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.open_line_above.is_pressed(event) {
|
||||
self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenAbove));
|
||||
if !self.single_line {
|
||||
self.start_vim_edit(VimAction::Insert(VimInsertPosition::OpenAbove));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.vim_normal_keymap.move_left.is_pressed(event) {
|
||||
@@ -1359,7 +1385,7 @@ impl TextArea {
|
||||
if self.kill_buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.kill_buffer_kind == KillBufferKind::Linewise {
|
||||
if self.kill_buffer_kind == KillBufferKind::Linewise && !self.single_line {
|
||||
self.paste_line_after_current_line();
|
||||
return;
|
||||
}
|
||||
@@ -1659,6 +1685,8 @@ impl TextArea {
|
||||
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
|
||||
/// updated without converting the element back into normal text.
|
||||
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
|
||||
let filtered = self.filter_line_breaks(new);
|
||||
let new = filtered.as_ref();
|
||||
let Some(idx) = self
|
||||
.elements
|
||||
.iter()
|
||||
@@ -2131,6 +2159,10 @@ impl TextArea {
|
||||
|
||||
impl WidgetRef for &TextArea {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if self.single_line {
|
||||
StatefulWidgetRef::render_ref(self, area, buf, &mut TextAreaState::default());
|
||||
return;
|
||||
}
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
self.render_lines(
|
||||
area,
|
||||
@@ -2148,6 +2180,20 @@ impl StatefulWidgetRef for &TextArea {
|
||||
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
self.rendered_area.set(area);
|
||||
if self.single_line {
|
||||
if !area.is_empty() {
|
||||
let (start, _) = self.single_line_viewport(area.width);
|
||||
self.render_lines(
|
||||
area,
|
||||
buf,
|
||||
std::slice::from_ref(&(start..self.text.len() + 1)),
|
||||
0..1,
|
||||
Style::default(),
|
||||
&[],
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let lines = self.wrapped_lines(area.width);
|
||||
let scroll = self.effective_scroll(area, &lines, *state);
|
||||
state.scroll = scroll;
|
||||
|
||||
@@ -39,6 +39,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_str_at_target(&mut self, target: EditTarget, text: &str) {
|
||||
let filtered = self.filter_line_breaks(text);
|
||||
let text = filtered.as_ref();
|
||||
// Empty text must not delete a selection or create a Vim replacement step.
|
||||
if text.is_empty() {
|
||||
if !self.is_vim_replace_mode() {
|
||||
@@ -64,6 +66,8 @@ impl TextArea {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_element_at_target(&mut self, target: EditTarget, text: &str) -> u64 {
|
||||
let filtered = self.filter_line_breaks(text);
|
||||
let text = filtered.as_ref();
|
||||
let start = target.start();
|
||||
if target.replaces_text() {
|
||||
self.vim_commands = VimCommandState::default();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Single-line editing strips line breaks and keeps a grapheme-aligned cursor viewport.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl TextArea {
|
||||
pub(crate) fn new_single_line() -> Self {
|
||||
Self {
|
||||
single_line: true,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn filter_line_breaks<'a>(&self, text: &'a str) -> Cow<'a, str> {
|
||||
if self.single_line && text.contains(['\r', '\n']) {
|
||||
Cow::Owned(text.replace(['\r', '\n'], ""))
|
||||
} else {
|
||||
Cow::Borrowed(text)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn single_line_viewport(&self, width: u16) -> (usize, u16) {
|
||||
let cursor_width = self.text[self.cursor_pos..]
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.next()
|
||||
.map_or(/*default*/ 1, display_width)
|
||||
.max(/*other*/ 1)
|
||||
.min(usize::from(width));
|
||||
let mut start = self.cursor_pos;
|
||||
let mut column = 0;
|
||||
for (offset, grapheme) in self.text[..self.cursor_pos]
|
||||
.grapheme_indices(/*is_extended*/ true)
|
||||
.rev()
|
||||
{
|
||||
let grapheme_width = display_width(grapheme);
|
||||
if column + grapheme_width > usize::from(width).saturating_sub(cursor_width) {
|
||||
break;
|
||||
}
|
||||
start = offset;
|
||||
column += grapheme_width;
|
||||
}
|
||||
(start, column as u16)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "single_line_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn single_line_vim_newline_commands_are_noops_and_yanks_stay_inline() {
|
||||
let mut input = TextArea::new_single_line();
|
||||
input.set_vim_enabled(/*enabled*/ true);
|
||||
input.set_text_clearing_elements("one\r\ntwo");
|
||||
assert_eq!(input.text(), "onetwo");
|
||||
input.set_text_clearing_elements("");
|
||||
input.enter_vim_insert_mode();
|
||||
input.insert_str("word");
|
||||
for event in [
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
|
||||
] {
|
||||
input.input(event);
|
||||
}
|
||||
assert_eq!(input.text(), "word");
|
||||
input.input(KeyCode::Esc.into());
|
||||
input.input(KeyCode::Char('0').into());
|
||||
input.input(KeyCode::Char('.').into());
|
||||
assert_eq!(input.text(), "wordword");
|
||||
let cursor = input.cursor_pos;
|
||||
for code in ['o', 'O'] {
|
||||
input.input(KeyCode::Char(code).into());
|
||||
assert_eq!(
|
||||
(input.text(), input.cursor_pos, input.is_vim_normal_mode()),
|
||||
("wordword", cursor, true)
|
||||
);
|
||||
}
|
||||
input.input(KeyCode::Char('r').into());
|
||||
input.input(KeyCode::Enter.into());
|
||||
assert_eq!((input.text(), input.cursor_pos), ("wordword", cursor));
|
||||
for code in ['y', 'y', 'p'] {
|
||||
input.input(KeyCode::Char(code).into());
|
||||
}
|
||||
assert_eq!(input.text(), "wordwordwordword");
|
||||
}
|
||||
@@ -548,7 +548,9 @@ impl TextArea {
|
||||
pub(super) fn handle_vim_pending_command(&mut self, pending: VimPending, event: KeyEvent) {
|
||||
match pending {
|
||||
VimPending::Replace => {
|
||||
if let Some(ch) = vim_command_char(event) {
|
||||
if let Some(ch) = vim_command_char(event)
|
||||
&& !(self.single_line && matches!(ch, '\r' | '\n'))
|
||||
{
|
||||
self.start_vim_edit(VimAction::Replace(ch));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1720,6 +1720,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_vim_enabled(&self) -> bool {
|
||||
self.bottom_pane.composer_is_vim_enabled()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_task_running_for_test(&self) -> bool {
|
||||
self.bottom_pane.is_task_running()
|
||||
|
||||
Reference in New Issue
Block a user