aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMark Jaquith <mark@jaquith.me>2026-09-14 15:47:53 -0400
committerGitHub <noreply@github.com>2026-09-14 21:47:53 +0200
commitb0f21ed2cb85613050b794bf6c78a7c367a94e0d (patch)
tree25c14e09338f5300bedbc8131aa23fab3d37add9
parentd8b36691d10c6055293ad45324b22af8b8f570e5 (diff)
feat: add cursor-based editing to text inputs (#3698)
* feat: add cursor-based editing to text inputs refs #1803 * fix: preserve dialog actions with associated text refs #1803 * refactor: simplify text editor outcomes and tests refs #1803 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml1
-rw-r--r--src/client/shell.rs26
-rw-r--r--src/client/shell/actions.rs2
-rw-r--r--src/client/shell/composition.rs6
-rw-r--r--src/client/shell/context_menu.rs12
-rw-r--r--src/client/shell/copy_mode.rs39
-rw-r--r--src/client/shell/input.rs26
-rw-r--r--src/client/shell/mouse.rs1
-rw-r--r--src/client/shell/overlay_input.rs175
-rw-r--r--src/client/shell/overlays.rs76
-rw-r--r--src/client/shell/render.rs41
-rw-r--r--src/client/shell/state.rs14
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs2
-rw-r--r--src/client/shell/tests/copy.rs11
-rw-r--r--src/client/shell/tests/graphics.rs8
-rw-r--r--src/client/shell/tests/input.rs16
-rw-r--r--src/client/shell/tests/mod.rs1
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs15
-rw-r--r--src/client/shell/tests/text_editing.rs395
-rw-r--r--src/client/shell/tests/workspace_navigation.rs5
-rw-r--r--src/client/shell/text_editor.rs526
-rw-r--r--src/client/shell/worktree_overlays.rs41
-rw-r--r--src/client/shell/worktrees.rs108
24 files changed, 1201 insertions, 347 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 08fe5cc6..254c4ad7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -697,6 +697,7 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
+ "unicode-segmentation",
"unicode-width",
"widestring",
"windows-sys",
diff --git a/Cargo.toml b/Cargo.toml
index d28ba46b..25c71d05 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,6 +45,7 @@ toml = "0.8"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
unicode-width = "0.2"
+unicode-segmentation = "1.13.1"
schemars = { version = "1.2.1", features = ["derive"] }
[patch.crates-io]
diff --git a/src/client/shell.rs b/src/client/shell.rs
index c6c7f2ed..95ee5e78 100644
--- a/src/client/shell.rs
+++ b/src/client/shell.rs
@@ -32,8 +32,10 @@ mod scroll;
mod settings;
mod state;
mod surface_patch;
+mod text_editor;
mod word_selection;
mod worktrees;
+use text_editor::TextEditor;
use word_selection::ClientWordSelection;
pub(in crate::client::shell) use render::sidebar;
@@ -64,30 +66,6 @@ use crate::protocol::{
#[cfg(test)]
use crate::raw_input::RawInputEvent;
-fn delete_overlay_word(rename: &mut ClientRenameOverlay) {
- if rename.replace_on_type {
- rename.input.clear();
- rename.replace_on_type = false;
- return;
- }
- while rename.input.chars().last().is_some_and(char::is_whitespace) {
- rename.input.pop();
- }
- let Some(word) = rename
- .input
- .chars()
- .last()
- .map(|character| character.is_alphanumeric() || character == '_')
- else {
- return;
- };
- while rename.input.chars().last().is_some_and(|character| {
- !character.is_whitespace() && (character.is_alphanumeric() || character == '_') == word
- }) {
- rename.input.pop();
- }
-}
-
fn target_event_message(target: ClientInputTarget, event: ClientPaneInputEvent) -> ClientMessage {
match target {
ClientInputTarget::Pane(pane_id) => ClientMessage::ClientShellPaneInput {
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs
index 95a24a54..48dab344 100644
--- a/src/client/shell/actions.rs
+++ b/src/client/shell/actions.rs
@@ -50,7 +50,7 @@ impl ClientShellState {
}
if action == crate::input::KeybindAction::Help {
self.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
scroll: 0,
}));
diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs
index cee57d96..0a577295 100644
--- a/src/client/shell/composition.rs
+++ b/src/client/shell/composition.rs
@@ -394,12 +394,14 @@ impl ClientShellState {
.saturating_sub(copy_mode.offset_from_bottom)
.min(u32::MAX as usize) as u32;
let viewport_row = copy_mode.cursor.row.saturating_sub(viewport_top);
+ let x = hit.inner_rect.x.saturating_add(copy_mode.cursor.col);
+ let y = hit.inner_rect.y.saturating_add(viewport_row as u16);
if viewport_row < u32::from(hit.inner_rect.height)
&& copy_mode.cursor.col < hit.inner_rect.width
+ && x < frame.width
+ && y < frame.height
{
let mut composed = frame.to_ratatui_buffer()?;
- let x = hit.inner_rect.x + copy_mode.cursor.col;
- let y = hit.inner_rect.y + viewport_row as u16;
occlusion.cover(Rect::new(x, y, 1, 1));
composed[(x, y)].set_style(
Style::default()
diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs
index 926510c5..96f57178 100644
--- a/src/client/shell/context_menu.rs
+++ b/src/client/shell/context_menu.rs
@@ -240,8 +240,7 @@ impl ClientShellState {
if let Some(label) = label {
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename workspace",
- input: label,
- replace_on_type: false,
+ input: TextEditor::new(&label, false),
target: ClientRenameTarget::Workspace { workspace_id },
}));
}
@@ -322,8 +321,7 @@ impl ClientShellState {
.to_string();
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "new tab",
- input: default_name.clone(),
- replace_on_type: true,
+ input: TextEditor::new(&default_name, true),
target: ClientRenameTarget::NewTab {
workspace_id,
default_name,
@@ -350,8 +348,7 @@ impl ClientShellState {
if let Some(tab) = tab {
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename tab",
- input: tab.label.clone(),
- replace_on_type: false,
+ input: TextEditor::new(&tab.label, false),
target: ClientRenameTarget::Tab {
tab_id,
auto_name: !tab.custom_label,
@@ -392,8 +389,7 @@ impl ClientShellState {
});
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: label.clone().unwrap_or_default(),
- replace_on_type: label.is_none(),
+ input: TextEditor::new(label.as_deref().unwrap_or_default(), label.is_none()),
target: ClientRenameTarget::Pane { pane_id },
}));
}
diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs
index f08c4afb..9750e19e 100644
--- a/src/client/shell/copy_mode.rs
+++ b/src/client/shell/copy_mode.rs
@@ -279,38 +279,18 @@ impl ClientShellState {
}
}
KeyCode::Enter => {
- submit = Some((prompt.query.clone(), prompt.direction));
+ submit = Some((prompt.query.to_string(), prompt.direction));
if let Some(copy_mode) = self.copy_mode.as_mut() {
copy_mode.search_prompt = None;
}
}
- KeyCode::Backspace => {
- if let Some(prompt) = self
- .copy_mode
- .as_mut()
- .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
- {
- prompt.query.pop();
- }
- }
- KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ _ => {
if let Some(prompt) = self
.copy_mode
.as_mut()
.and_then(|copy_mode| copy_mode.search_prompt.as_mut())
{
- prompt.query.clear();
- }
- }
- _ => {
- if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) {
- if let Some(prompt) = self
- .copy_mode
- .as_mut()
- .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
- {
- prompt.query.push(ch);
- }
+ prompt.query.handle_key(key);
}
}
}
@@ -322,6 +302,13 @@ impl ClientShellState {
}
pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool {
+ if self.mode != ClientShellMode::Copy
+ || self.overlay.is_some()
+ || self.popup_terminal_id.is_some()
+ || self.popup_pending
+ {
+ return false;
+ }
let Some(prompt) = self
.copy_mode
.as_mut()
@@ -329,9 +316,7 @@ impl ClientShellState {
else {
return false;
};
- prompt
- .query
- .extend(text.chars().filter(|character| !character.is_control()));
+ prompt.query.insert(text);
true
}
@@ -341,7 +326,7 @@ impl ClientShellState {
};
copy_mode.search_prompt = Some(ClientCopySearchPrompt {
direction,
- query: String::new(),
+ query: TextEditor::default(),
});
}
diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs
index eb1f8f2f..c6d458c8 100644
--- a/src/client/shell/input.rs
+++ b/src/client/shell/input.rs
@@ -14,9 +14,10 @@ pub(super) fn is_modal_paste_shortcut_for_platform(
key: &crate::input::TerminalKey,
macos: bool,
) -> bool {
- matches!(key.code, KeyCode::Char('v' | 'V'))
- && (key.modifiers.contains(KeyModifiers::CONTROL)
- || macos && key.modifiers.contains(KeyModifiers::SUPER))
+ key.generated_text.as_deref().is_none_or(str::is_empty)
+ && matches!(key.code, KeyCode::Char('v' | 'V'))
+ && (key.modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::CONTROL
+ || macos && key.modifiers.difference(KeyModifiers::SHIFT) == KeyModifiers::SUPER)
}
fn is_modal_paste_shortcut(key: &crate::input::TerminalKey) -> bool {
@@ -444,12 +445,14 @@ impl ClientShellState {
{
return false;
}
- if self
- .copy_mode
- .as_ref()
- .is_some_and(|copy_mode| copy_mode.search_prompt.is_some())
+ if self.mode == ClientShellMode::Copy
+ && self.overlay.is_none()
+ && self
+ .copy_mode
+ .as_ref()
+ .is_some_and(|copy_mode| copy_mode.search_prompt.is_some())
{
- return self.overlay.is_none();
+ return true;
}
matches!(
self.overlay.as_ref(),
@@ -609,7 +612,12 @@ impl ClientShellState {
None
}
ClientShellMode::Copy => {
- if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) {
+ if self
+ .copy_mode
+ .as_ref()
+ .is_none_or(|copy_mode| copy_mode.search_prompt.is_none())
+ && crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix)
+ {
self.mode = ClientShellMode::Prefix;
outcome.repaint = true;
} else {
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs
index 1ce14feb..1828d7f6 100644
--- a/src/client/shell/mouse.rs
+++ b/src/client/shell/mouse.rs
@@ -1679,7 +1679,6 @@ impl ClientShellState {
} else if super::contains(self.hits.overlay_clear, point) {
if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() {
rename.input.clear();
- rename.replace_on_type = false;
outcome.repaint = true;
}
} else {
diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs
index 42e331af..7cf151cb 100644
--- a/src/client/shell/overlay_input.rs
+++ b/src/client/shell/overlay_input.rs
@@ -200,7 +200,7 @@ impl ClientShellState {
})
.collect();
let mut navigator = ClientNavigatorOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
@@ -344,8 +344,7 @@ impl ClientShellState {
.unwrap_or_else(|| "workspace".to_owned());
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "new workspace",
- input: suggested_name.clone(),
- replace_on_type: true,
+ input: TextEditor::new(&suggested_name, true),
target: ClientRenameTarget::NewWorkspace {
source_workspace_id,
cwd,
@@ -370,8 +369,7 @@ impl ClientShellState {
};
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename workspace",
- input: workspace.label.clone(),
- replace_on_type: false,
+ input: TextEditor::new(&workspace.label, false),
target: ClientRenameTarget::Workspace { workspace_id },
}));
}
@@ -392,8 +390,7 @@ impl ClientShellState {
.to_string();
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "new tab",
- input: default_name.clone(),
- replace_on_type: true,
+ input: TextEditor::new(&default_name, true),
target: ClientRenameTarget::NewTab {
workspace_id,
default_name,
@@ -413,8 +410,7 @@ impl ClientShellState {
};
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename tab",
- input: tab.label.clone(),
- replace_on_type: false,
+ input: TextEditor::new(&tab.label, false),
target: ClientRenameTarget::Tab {
tab_id: tab.tab_id.clone(),
auto_name: !tab.custom_label,
@@ -435,8 +431,10 @@ impl ClientShellState {
};
self.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: pane.label.clone().unwrap_or_default(),
- replace_on_type: pane.label.is_none(),
+ input: TextEditor::new(
+ pane.label.as_deref().unwrap_or_default(),
+ pane.label.is_none(),
+ ),
target: ClientRenameTarget::Pane {
pane_id: pane.pane_id.clone(),
},
@@ -449,23 +447,20 @@ impl ClientShellState {
}
match self.overlay.as_mut() {
Some(ClientShellOverlay::Rename(rename)) => {
- if rename.replace_on_type {
- rename.input.clear();
- rename.replace_on_type = false;
- }
- rename.input.push_str(text);
+ rename.input.insert(text);
true
}
Some(ClientShellOverlay::Help(help)) if help.search_focused => {
- help.query
- .extend(text.chars().filter(|character| !character.is_control()));
- help.scroll = 0;
+ if help.query.insert(text) {
+ help.scroll = 0;
+ }
true
}
Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused => {
- navigator.query.push_str(text);
- navigator.filter = None;
- navigator.selected = None;
+ if navigator.query.insert(text) {
+ navigator.filter = None;
+ navigator.selected = None;
+ }
true
}
_ => false,
@@ -655,42 +650,30 @@ impl ClientShellState {
return;
}
if search_focused {
+ if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
+ if let Some(content_changed) = navigator.query.handle_key(key) {
+ if content_changed {
+ navigator.filter = None;
+ navigator.selected = None;
+ }
+ outcome.repaint = true;
+ return;
+ }
+ }
if code == KeyCode::Up
- || code == KeyCode::Char('p') && modifiers.contains(KeyModifiers::CONTROL)
+ || code == KeyCode::Char('p') && modifiers == KeyModifiers::CONTROL
{
self.move_navigator_selection(-1);
outcome.repaint = true;
return;
}
if code == KeyCode::Down
- || code == KeyCode::Char('n') && modifiers.contains(KeyModifiers::CONTROL)
+ || code == KeyCode::Char('n') && modifiers == KeyModifiers::CONTROL
{
self.move_navigator_selection(1);
outcome.repaint = true;
return;
}
- if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
- if code == KeyCode::Char('u') && modifiers.contains(KeyModifiers::CONTROL) {
- navigator.query.clear();
- navigator.filter = None;
- navigator.selected = None;
- } else if code == KeyCode::Backspace {
- navigator.query.pop();
- navigator.filter = None;
- navigator.selected = None;
- } else if let KeyCode::Char(character) = code {
- if modifiers.difference(KeyModifiers::SHIFT).is_empty() {
- navigator.filter = None;
- if let Some(text) = key.generated_text.as_deref() {
- navigator.query.push_str(text);
- } else {
- navigator.query.push(character);
- }
- navigator.selected = None;
- }
- }
- outcome.repaint = true;
- }
return;
}
if code == KeyCode::Backspace && modifiers.is_empty() {
@@ -798,6 +781,15 @@ impl ClientShellState {
}))
);
if search_focused {
+ if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
+ if let Some(content_changed) = help.query.handle_key(key) {
+ if content_changed {
+ help.scroll = 0;
+ }
+ outcome.repaint = true;
+ return;
+ }
+ }
match code {
KeyCode::Esc => {
if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
@@ -807,20 +799,17 @@ impl ClientShellState {
}
}
KeyCode::Enter => self.overlay = None,
- KeyCode::Home => {
- if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
- help.scroll = 0;
- }
- }
- KeyCode::End => {
- if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
- help.scroll = self.hits.help_max_scroll;
- }
- }
- KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => {
+ KeyCode::Up
+ | KeyCode::Down
+ | KeyCode::PageUp
+ | KeyCode::PageDown
+ | KeyCode::Char('n' | 'p')
+ if !matches!(code, KeyCode::Char(_))
+ || modifiers == KeyModifiers::CONTROL =>
+ {
let delta = match code {
- KeyCode::Up => -1,
- KeyCode::Down => 1,
+ KeyCode::Up | KeyCode::Char('p') => -1,
+ KeyCode::Down | KeyCode::Char('n') => 1,
KeyCode::PageUp => -8,
KeyCode::PageDown => 8,
_ => unreachable!(),
@@ -832,26 +821,7 @@ impl ClientShellState {
.min(self.hits.help_max_scroll);
}
}
- KeyCode::Backspace => {
- if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
- help.query.pop();
- help.scroll = 0;
- }
- }
- KeyCode::Char('u') if modifiers == KeyModifiers::CONTROL => {
- if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
- help.query.clear();
- help.scroll = 0;
- }
- }
- _ => {
- if let Some(character) = text_character {
- if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() {
- help.query.push(character);
- help.scroll = 0;
- }
- }
- }
+ _ => {}
}
outcome.repaint = true;
return;
@@ -939,53 +909,26 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
- if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
- rename.input.clear();
- rename.replace_on_type = false;
- outcome.repaint = true;
+ if key
+ .generated_text
+ .as_deref()
+ .is_some_and(|text| !text.is_empty())
+ {
+ outcome.repaint |= rename.input.handle_key(key).is_some();
return;
}
- if (key.code == KeyCode::Char('u') && key.modifiers.contains(KeyModifiers::CONTROL))
- || (key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER))
- {
+ if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
rename.input.clear();
- rename.replace_on_type = false;
outcome.repaint = true;
return;
}
- if key.code == KeyCode::Backspace
- && (key.modifiers.contains(KeyModifiers::CONTROL)
- || key.modifiers.contains(KeyModifiers::ALT))
- || matches!(key.code, KeyCode::Char('h' | 'w'))
- && key.modifiers.contains(KeyModifiers::CONTROL)
- {
- delete_overlay_word(rename);
+ if key.code == KeyCode::Backspace && key.modifiers.contains(KeyModifiers::SUPER) {
+ rename.input.clear();
outcome.repaint = true;
return;
}
- if key.code == KeyCode::Backspace {
- if rename.replace_on_type {
- rename.input.clear();
- rename.replace_on_type = false;
- } else {
- rename.input.pop();
- }
+ if rename.input.handle_key(key).is_some() {
outcome.repaint = true;
- return;
- }
- if let KeyCode::Char(character) = key.code {
- if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
- if rename.replace_on_type {
- rename.input.clear();
- rename.replace_on_type = false;
- }
- if let Some(text) = key.generated_text.as_deref() {
- rename.input.push_str(text);
- } else {
- rename.input.push(character);
- }
- outcome.repaint = true;
- }
}
}
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs
index bc3772b1..07417bd0 100644
--- a/src/client/shell/overlays.rs
+++ b/src/client/shell/overlays.rs
@@ -645,12 +645,10 @@ fn render_rename_overlay(
);
let input = Rect::new(i.x, i.y + 2, i.width, 1);
b.set_style(input, Style::default().fg(p.text).bg(p.surface0));
- put_text(
+ let cursor = text_editor::render(
b,
- input.x,
- input.y,
- input.width.saturating_sub(1),
- &format!(" {}", v.input),
+ Rect::new(input.x + 1, input.y, input.width.saturating_sub(1), 1),
+ &v.input,
Style::default().fg(p.text).bg(p.surface0),
);
let rs = row(i, &[8, 10, 12], 2, 3);
@@ -682,12 +680,7 @@ fn render_rename_overlay(
navigator_rows: Vec::new(),
worktree_search: Rect::default(),
worktree_rows: Vec::new(),
- cursor: Some(crate::protocol::CursorState {
- x: (input.x + 1 + display_width(&v.input)).min(input.right() - 1),
- y: input.y,
- visible: true,
- shape: 0,
- }),
+ cursor,
..OverlayRender::default()
})
}
@@ -725,11 +718,12 @@ fn render_navigator_overlay(
a.y + my,
a.width.saturating_sub(mx * 2).max(4),
a.height.saturating_sub(my * 2).max(4),
- );
+ )
+ .intersection(a);
let i = panel(b, q, p.accent, p.panel_bg)?;
let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
let search = if n.search_focused {
- format!(" / {}", n.query)
+ " / ".to_owned()
} else if let Some(f) = n.filter {
format!(
" / {}",
@@ -755,16 +749,32 @@ fn render_navigator_overlay(
.fg(if n.search_focused { p.text } else { p.overlay0 })
.bg(p.panel_bg),
);
+ let count = format!(
+ "{} panes",
+ rows.iter()
+ .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
+ .count()
+ );
+ let cursor = if n.search_focused {
+ text_editor::render(
+ b,
+ Rect::new(
+ i.x + 3,
+ i.y,
+ i.width.saturating_sub(4 + display_width(&count)),
+ 1,
+ ),
+ &n.query,
+ Style::default().fg(p.text).bg(p.panel_bg),
+ )
+ } else {
+ None
+ };
put_right_text(
b,
i,
i.y,
- &format!(
- "{} panes",
- rows.iter()
- .filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
- .count()
- ),
+ &count,
Style::default().fg(p.overlay0).bg(p.panel_bg),
);
put_text(
@@ -940,12 +950,7 @@ fn render_navigator_overlay(
navigator_rows: row_hits,
worktree_search: Rect::default(),
worktree_rows: Vec::new(),
- cursor: n.search_focused.then(|| crate::protocol::CursorState {
- x: i.x + 3 + display_width(&n.query),
- y: i.y,
- visible: true,
- shape: 0,
- }),
+ cursor,
..OverlayRender::default()
})
}
@@ -1059,7 +1064,7 @@ fn render_help_overlay(
sy,
i.width,
&if h.search_focused {
- format!(" / {}", h.query)
+ " / ".to_owned()
} else {
" / press / to filter by command or shortcut".to_owned()
},
@@ -1067,6 +1072,16 @@ fn render_help_overlay(
.fg(if h.search_focused { p.text } else { p.overlay0 })
.bg(p.panel_bg),
);
+ let cursor = if h.search_focused {
+ text_editor::render(
+ b,
+ Rect::new(i.x + 3, sy, i.width.saturating_sub(3), 1),
+ &h.query,
+ Style::default().fg(p.text).bg(p.panel_bg),
+ )
+ } else {
+ None
+ };
let body = Rect::new(i.x, i.y + 3, i.width, i.height.saturating_sub(5));
let lines = help_lines(k, &h.query, p);
@@ -1126,7 +1141,7 @@ fn render_help_overlay(
i.bottom() - 1,
i.width,
if h.search_focused {
- " filter type/backspace · clear ctrl+u · scroll ↑↓/pgup/pgdn · back esc"
+ " edit ←→/home/end · kill ^u/^k · yank ^y · scroll ↑↓ · back esc"
} else {
" search / · scroll j/k/↑↓/pgup/pgdn · close esc/enter"
},
@@ -1139,12 +1154,7 @@ fn render_help_overlay(
help_scrollbar: scrollbar.unwrap_or_default(),
help_scroll_metrics: Some(metrics),
help_max_scroll: max_scroll,
- cursor: h.search_focused.then(|| crate::protocol::CursorState {
- x: (i.x + 3 + display_width(&h.query)).min(i.right() - 1),
- y: sy,
- visible: true,
- shape: 0,
- }),
+ cursor,
..OverlayRender::default()
})
}
diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs
index d51586a0..80387019 100644
--- a/src/client/shell/render.rs
+++ b/src/client/shell/render.rs
@@ -128,17 +128,36 @@ pub(super) fn render_mode_bar(
crate::api::schema::PaneCopySearchDirection::Forward => "/",
crate::api::schema::PaneCopySearchDirection::Backward => "?",
};
- segments.extend([
- (" COPY ".to_owned(), mode_style),
- (" ".to_owned(), base),
- (marker.to_owned(), key),
- (
- prompt.query.clone(),
- Style::default().fg(palette.text).bg(palette.panel_bg),
- ),
- ("█".to_owned(), key),
- (" enter search esc cancel".to_owned(), base),
- ]);
+ buffer.set_stringn(bar.x, bar.y, " COPY ", usize::from(bar.width), mode_style);
+ let prefix = 8.min(bar.width);
+ if bar.width >= 8 {
+ buffer.set_string(bar.x + 7, bar.y, marker, key);
+ }
+ let footer = " enter search esc cancel";
+ let footer_width = if bar.width >= 50 {
+ footer.len() as u16
+ } else {
+ 0
+ };
+ let field = Rect::new(
+ bar.x + prefix,
+ bar.y,
+ bar.width.saturating_sub(prefix + footer_width),
+ 1,
+ );
+ if let Some(cursor) = text_editor::render(
+ buffer,
+ field,
+ &prompt.query,
+ Style::default().fg(palette.text).bg(palette.panel_bg),
+ ) {
+ buffer[(cursor.x, cursor.y)]
+ .set_style(Style::default().fg(palette.panel_bg).bg(palette.text));
+ }
+ if footer_width > 0 {
+ buffer.set_string(bar.right() - footer_width, bar.y, footer, base);
+ }
+ return Some(bar);
} else {
let select = if copy_mode.selection.is_some() {
"selecting"
diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs
index 04e87169..f61295c8 100644
--- a/src/client/shell/state.rs
+++ b/src/client/shell/state.rs
@@ -372,8 +372,7 @@ pub(super) enum ClientRenameTarget {
#[derive(Debug)]
pub(super) struct ClientRenameOverlay {
pub(super) title: &'static str,
- pub(super) input: String,
- pub(super) replace_on_type: bool,
+ pub(super) input: TextEditor,
pub(super) target: ClientRenameTarget,
}
@@ -417,7 +416,7 @@ pub(super) struct ClientNavigatorRow {
#[derive(Debug)]
pub(super) struct ClientNavigatorOverlay {
- pub(super) query: String,
+ pub(super) query: TextEditor,
pub(super) search_focused: bool,
pub(super) selected: Option<ClientNavigatorTarget>,
pub(super) scroll: usize,
@@ -427,7 +426,7 @@ pub(super) struct ClientNavigatorOverlay {
#[derive(Debug)]
pub(super) struct ClientHelpOverlay {
- pub(super) query: String,
+ pub(super) query: TextEditor,
pub(super) search_focused: bool,
pub(super) scroll: usize,
}
@@ -482,9 +481,8 @@ pub(super) struct ClientSettingsOverlay {
pub(super) struct ClientWorktreeCreateOverlay {
pub(super) source_workspace_id: String,
pub(super) repo_name: String,
- pub(super) branch: String,
+ pub(super) branch: TextEditor,
pub(super) checkout_path: String,
- pub(super) replace_on_type: bool,
pub(super) error: Option<String>,
pub(super) creating: bool,
}
@@ -532,7 +530,7 @@ pub(super) struct ClientWorktreeOpenOverlay {
pub(super) source_workspace_id: String,
pub(super) entries: Vec<ClientWorktreeOpenEntry>,
pub(super) selected: usize,
- pub(super) query: String,
+ pub(super) query: TextEditor,
pub(super) search_focused: bool,
pub(super) error: Option<String>,
pub(super) opening: bool,
@@ -850,7 +848,7 @@ pub(super) enum ClientCopySelection {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ClientCopySearchPrompt {
pub(super) direction: crate::api::schema::PaneCopySearchDirection,
- pub(super) query: String,
+ pub(super) query: TextEditor,
}
#[derive(Clone, Debug, PartialEq, Eq)]
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index c89991b1..5b8b1224 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -917,7 +917,7 @@ fn named_workspace_overlay_targets_projected_source_workspace() {
..
},
..
- })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1")
+ })) if value.as_str() == "repo" && source_workspace_id.as_deref() == Some("ws_1")
));
let create = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index c83f7fc6..8ee35f45 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -1,10 +1,10 @@
use super::*;
#[test]
-fn pasted_help_and_copy_queries_strip_control_characters() {
+fn pasted_help_and_copy_queries_normalize_single_line_text() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: true,
scroll: 0,
}));
@@ -13,10 +13,11 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. }))
- if query == "workspace"
+ if query.as_str() == "work space"
));
state.overlay = None;
+ state.mode = ClientShellMode::Copy;
state.copy_mode = Some(ClientCopyModeState {
pane_id: "pane_1".into(),
content_revision: 0,
@@ -28,7 +29,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
selection: None,
search_prompt: Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
- query: String::new(),
+ query: TextEditor::default(),
}),
search_query: String::new(),
search_direction: None,
@@ -47,7 +48,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
.as_ref()
.and_then(|copy_mode| copy_mode.search_prompt.as_ref())
.map(|prompt| prompt.query.as_str()),
- Some("needle")
+ Some("needle ")
);
}
diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs
index 1b0071f1..c5fa1f4a 100644
--- a/src/client/shell/tests/graphics.rs
+++ b/src/client/shell/tests/graphics.rs
@@ -210,7 +210,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename",
input: "name".into(),
- replace_on_type: false,
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -221,12 +220,12 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
detail: "confirm".into(),
}),
ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
scroll: 0,
}),
ClientShellOverlay::Navigator(ClientNavigatorOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
@@ -238,7 +237,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
repo_name: "repo".into(),
branch: "branch".into(),
checkout_path: "path".into(),
- replace_on_type: false,
error: None,
creating: false,
}),
@@ -246,7 +244,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
source_workspace_id: "ws_1".into(),
entries: Vec::new(),
selected: 0,
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
error: None,
opening: false,
diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs
index 35916ecf..b55cf9a2 100644
--- a/src/client/shell/tests/input.rs
+++ b/src/client/shell/tests/input.rs
@@ -107,6 +107,15 @@ fn full_host_palette_response_is_sent_as_one_theme_update() {
#[test]
fn modal_paste_shortcut_modifiers_are_platform_specific() {
let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers);
+ for macos in [false, true] {
+ assert!(!input::is_modal_paste_shortcut_for_platform(
+ &key(
+ KeyCode::Char('v'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ ),
+ macos
+ ));
+ }
assert!(input::is_modal_paste_shortcut_for_platform(
&key(KeyCode::Char('v'), KeyModifiers::CONTROL),
@@ -142,8 +151,7 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: "replace me".into(),
- replace_on_type: true,
+ input: TextEditor::new("replace me", true),
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -159,8 +167,8 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() {
assert!(outcome.repaint);
assert!(matches!(
state.overlay,
- Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. }))
- if input == "feature/pasted"
+ Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, .. }))
+ if input.as_str() == "feature/pasted"
));
}
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index b6b600bc..787d8a75 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -5,6 +5,7 @@ use crate::protocol::{
PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect,
};
use crossterm::event::MouseEvent;
+mod text_editing;
pub(super) fn snapshot() -> ClientShellSnapshot {
ClientShellSnapshot {
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 044ef145..aa44bf6d 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -37,8 +37,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: String::new(),
- replace_on_type: false,
+ input: TextEditor::default(),
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -49,9 +48,8 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
ClientWorktreeCreateOverlay {
source_workspace_id: "ws_1".into(),
repo_name: "repo".into(),
- branch: String::new(),
+ branch: TextEditor::default(),
checkout_path: String::new(),
- replace_on_type: false,
error: None,
creating: true,
},
@@ -67,7 +65,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
source_workspace_id: "ws_1".into(),
entries: Vec::new(),
selected: 0,
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
error: None,
opening: false,
@@ -80,7 +78,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
assert!(state.modal_paste_target_active());
state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
@@ -94,7 +92,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
assert!(state.modal_paste_target_active());
state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
scroll: 0,
}));
@@ -116,7 +114,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
selection: None,
search_prompt: Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
- query: String::new(),
+ query: TextEditor::default(),
}),
search_query: String::new(),
search_direction: None,
@@ -127,6 +125,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
search_generation: 0,
copy_after_search: false,
});
+ state.mode = ClientShellMode::Copy;
assert!(state.modal_paste_target_active());
state.popup_pending = true;
assert!(!state.modal_paste_target_active());
diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs
new file mode 100644
index 00000000..b63ce9f6
--- /dev/null
+++ b/src/client/shell/tests/text_editing.rs
@@ -0,0 +1,395 @@
+use super::*;
+use crate::input::{KeybindAction, KeybindMatch, TerminalKey, TextCommit};
+
+fn shell(field: usize) -> ClientShellState {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ let mut frame = surface();
+ frame.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 0,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(frame);
+ state.compose(106, 30).expect("initial shell");
+ match field {
+ 0 => state.open_new_workspace_overlay(),
+ 1 => state.open_rename_workspace_overlay(),
+ 2 => state.open_new_tab_overlay(),
+ 3 => state.open_rename_tab_overlay(),
+ 4 => state.open_rename_pane_overlay(),
+ 5 => {
+ state.handle_worktree_endpoint_result(
+ PendingEndpointKind::PrepareWorktreeCreate {
+ workspace_id: "ws_1".into(),
+ },
+ Ok(worktree_list_result(None)),
+ &mut ClientShellInput::default(),
+ );
+ }
+ 6 => {
+ state.open_navigator_overlay();
+ state.handle_input_bytes(b"/");
+ }
+ 7 => {
+ state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
+ query: TextEditor::default(),
+ search_focused: true,
+ scroll: 0,
+ }))
+ }
+ 8 => {
+ state.handle_worktree_endpoint_result(
+ PendingEndpointKind::PrepareWorktreeOpen {
+ workspace_id: "ws_1".into(),
+ },
+ Ok(worktree_list_result(None)),
+ &mut ClientShellInput::default(),
+ );
+ state.handle_input_bytes(b"/");
+ }
+ 9 => {
+ state.record_binding(
+ KeybindMatch::Action(KeybindAction::CopyMode),
+ &mut ClientShellInput::default(),
+ );
+ state.handle_input_bytes(b"/");
+ }
+ _ => unreachable!(),
+ }
+ state
+}
+
+fn editor(state: &mut ClientShellState) -> &mut TextEditor {
+ match state.overlay.as_mut() {
+ Some(ClientShellOverlay::Rename(v)) => &mut v.input,
+ Some(ClientShellOverlay::Navigator(v)) => &mut v.query,
+ Some(ClientShellOverlay::Help(v)) => &mut v.query,
+ Some(ClientShellOverlay::WorktreeCreate(v)) => &mut v.branch,
+ Some(ClientShellOverlay::WorktreeOpen(v)) => &mut v.query,
+ _ => {
+ &mut state
+ .copy_mode
+ .as_mut()
+ .expect("copy mode")
+ .search_prompt
+ .as_mut()
+ .expect("prompt")
+ .query
+ }
+ }
+}
+
+fn press(state: &mut ClientShellState, code: KeyCode, modifiers: KeyModifiers) -> ClientShellInput {
+ state.handle_raw_events(vec![RawInputEvent::Key(TerminalKey::new(code, modifiers))])
+}
+
+#[test]
+fn all_ten_fields_route_shared_text_editing() {
+ for field in 0..10 {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Left, KeyModifiers::NONE);
+ let result = press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE);
+ assert!(result.repaint, "field {field}");
+ assert!(result.requests.is_empty() && result.actions.is_empty());
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ }
+}
+
+#[test]
+fn text_delivery_paths_insert_at_the_cursor() {
+ for delivery in 0..4 {
+ let mut state = shell(0);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Left, KeyModifiers::NONE);
+ let result = match delivery {
+ 0 => state.handle_raw_events(vec![RawInputEvent::Key(
+ TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE)
+ .with_generated_text(Some("X".into())),
+ )]),
+ 1 => state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]),
+ 2 => state.handle_raw_events(vec![RawInputEvent::Paste("X".into())]),
+ _ => {
+ let mut result = ClientShellInput::default();
+ assert!(state.handle_modal_paste_shortcut_with(
+ &TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
+ &mut result,
+ || Some("X".into())
+ ));
+ result
+ }
+ };
+ assert!(result.repaint, "delivery {delivery}");
+ assert!(result.requests.is_empty() && result.actions.is_empty());
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ }
+}
+
+#[test]
+fn rename_clear_exceptions_remain_local_and_copy_prompt_yields_to_popup() {
+ for field in 0..5 {
+ for (code, modifiers) in [
+ (KeyCode::Char('c'), KeyModifiers::CONTROL),
+ (KeyCode::Backspace, KeyModifiers::SUPER),
+ ] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("name");
+ press(&mut state, code, modifiers);
+ assert!(state.overlay.is_some());
+ assert!(editor(&mut state).is_empty());
+ }
+ }
+ let mut state = shell(9);
+ *editor(&mut state) = TextEditor::from("query");
+ state.popup_terminal_id = Some("popup-test".into());
+ let result = state.handle_raw_events(vec![
+ RawInputEvent::Text(TextCommit::new("text")),
+ RawInputEvent::Paste("paste".into()),
+ ]);
+ assert!(
+ matches!(&result.requests[..], [ClientMessage::ClientShellPopupInput { terminal_id, events }] if terminal_id == "popup-test" && events.len() == 2)
+ );
+ assert_eq!(editor(&mut state).as_str(), "query");
+ assert!(!state.modal_paste_target_active());
+}
+
+#[test]
+fn cursor_movement_preserves_filter_selection_scroll_and_branch_error() {
+ for field in [5, 6, 7, 8] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ match state.overlay.as_mut().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ v.checkout_path = "sentinel".into();
+ v.error = Some("error".into());
+ }
+ ClientShellOverlay::Navigator(v) => {
+ v.scroll = 3;
+ v.selected = Some(ClientNavigatorTarget::Pane {
+ endpoint_id: ClientEndpointId::Local,
+ pane_id: "pane_1".into(),
+ });
+ }
+ ClientShellOverlay::Help(v) => v.scroll = 3,
+ ClientShellOverlay::WorktreeOpen(v) => v.selected = 3,
+ _ => unreachable!(),
+ }
+ press(&mut state, KeyCode::Home, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('u'), KeyModifiers::CONTROL); // Empty kill must not refresh results.
+ match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ assert_eq!(v.checkout_path, "sentinel");
+ assert_eq!(v.error.as_deref(), Some("error"));
+ }
+ ClientShellOverlay::Navigator(v) => {
+ assert_eq!(v.scroll, 3);
+ assert!(v.selected.is_some());
+ }
+ ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 3),
+ ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 3),
+ _ => unreachable!(),
+ }
+ press(&mut state, KeyCode::Char('k'), KeyModifiers::CONTROL);
+ match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ assert_ne!(v.checkout_path, "sentinel");
+ assert!(v.error.is_none());
+ }
+ ClientShellOverlay::Navigator(v) => assert!(v.selected.is_none()),
+ ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 0),
+ ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 0),
+ _ => unreachable!(),
+ }
+ }
+}
+
+#[test]
+fn enter_and_escape_preserve_overlay_actions_with_generated_text() {
+ use crate::api::schema::Method;
+ for field in [5, 7, 8] {
+ for code in [KeyCode::Enter, KeyCode::Esc] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("feature");
+ let before = editor(&mut state).clone();
+ let result = state.handle_raw_events(vec![RawInputEvent::Key(
+ TerminalKey::new(code, KeyModifiers::NONE)
+ .with_generated_text(Some("printable".into())),
+ )]);
+ assert!(result.repaint);
+ assert!(result.requests.is_empty());
+ if code == KeyCode::Enter && field != 7 {
+ assert_eq!(editor(&mut state), &before);
+ let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else {
+ panic!("field {field} should submit");
+ };
+ match &request.method {
+ Method::WorktreeCreate(params) if field == 5 => {
+ assert_eq!(params.branch.as_deref(), Some("feature"));
+ }
+ Method::WorktreeOpen(params) if field == 8 => {
+ assert_eq!(params.path.as_deref(), Some("/repo-feature"));
+ }
+ _ => panic!("wrong method for field {field}"),
+ }
+ } else {
+ assert!(result.actions.is_empty());
+ if field == 7 && code == KeyCode::Esc {
+ let Some(ClientShellOverlay::Help(help)) = &state.overlay else {
+ panic!("Escape should leave help open");
+ };
+ assert!(!help.search_focused);
+ assert!(help.query.is_empty());
+ assert_eq!(help.scroll, 0);
+ } else {
+ assert!(state.overlay.is_none(), "field {field}, {code:?}");
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn busy_worktree_inputs_ignore_edits_paste_and_cancel() {
+ for field in [5, 8] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ match state.overlay.as_mut().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => v.creating = true,
+ ClientShellOverlay::WorktreeOpen(v) => v.opening = true,
+ _ => unreachable!(),
+ }
+ let before = editor(&mut state).clone();
+ for code in [
+ KeyCode::Left,
+ KeyCode::Backspace,
+ KeyCode::Esc,
+ KeyCode::Enter,
+ ] {
+ press(&mut state, code, KeyModifiers::NONE);
+ }
+ state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]);
+ assert_eq!(editor(&mut state), &before);
+ assert!(!state.modal_paste_target_active());
+ }
+}
+
+#[test]
+fn focused_filters_keep_ctrl_n_p_navigation_and_literal_commands() {
+ for field in [6, 7, 8] {
+ let mut state = shell(field);
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = None;
+ }
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() {
+ let mut second = open.entries[0].clone();
+ second.path = "/second".into();
+ open.entries.push(second);
+ }
+ state.compose(106, 30).expect("filter frame");
+ let before = match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected),
+ ClientShellOverlay::Help(v) => v.scroll.to_string(),
+ ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(),
+ _ => unreachable!(),
+ };
+ press(&mut state, KeyCode::Char('n'), KeyModifiers::CONTROL);
+ let after = match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected),
+ ClientShellOverlay::Help(v) => v.scroll.to_string(),
+ ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(),
+ _ => unreachable!(),
+ };
+ assert_ne!(before, after, "field {field}");
+ press(&mut state, KeyCode::Char('p'), KeyModifiers::CONTROL);
+ assert!(editor(&mut state).is_empty());
+ for ch in ['j', 'k', '?'] {
+ press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE);
+ }
+ assert_eq!(editor(&mut state).as_str(), "jk?");
+ }
+}
+
+#[test]
+fn all_naming_targets_preserve_submission_and_empty_semantics() {
+ use crate::api::schema::Method;
+ for field in 0..5 {
+ for empty in [false, true] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from(if empty { " " } else { " ab " });
+ if !empty {
+ press(&mut state, KeyCode::Home, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE);
+ }
+ let result = press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
+ assert!(state.overlay.is_none());
+ if empty && matches!(field, 1 | 3) {
+ assert!(result.actions.is_empty());
+ continue;
+ }
+ let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else {
+ panic!("naming target {field}");
+ };
+ let expected = if empty { "" } else { "X ab" };
+ match &request.method {
+ Method::WorkspaceCreate(v) => {
+ assert_eq!(v.label.as_deref(), (!empty).then_some(expected))
+ }
+ Method::WorkspaceRename(v) => assert_eq!(v.label, expected),
+ Method::TabCreate(v) => {
+ assert_eq!(v.label.as_deref(), (!empty).then_some(expected))
+ }
+ Method::TabRename(v) => assert_eq!(v.label, expected),
+ Method::PaneRename(v) => assert_eq!(v.label.as_deref(), Some(expected)),
+ _ => panic!("wrong method"),
+ }
+ }
+ }
+}
+
+#[test]
+fn copy_search_owns_prefix_but_parked_prompt_does_not_steal_input() {
+ let mut state = shell(9);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL);
+ assert_eq!(state.mode, ClientShellMode::Copy);
+ state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]);
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.open_rename_pane_overlay();
+ assert!(state.modal_paste_target_active());
+ state.handle_raw_events(vec![RawInputEvent::Paste("name".into())]);
+ assert_eq!(editor(&mut state).as_str(), "name");
+ state.overlay = None;
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.mode = ClientShellMode::Terminal;
+ assert!(!state.modal_paste_target_active());
+ let input = state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("terminal"))]);
+ assert!(
+ matches!(&input.requests[..], [ClientMessage::ClientShellPaneInput { events, .. }] if matches!(&events[..], [ClientPaneInputEvent::TextCommit(text)] if text == "terminal"))
+ );
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.mode = ClientShellMode::Copy;
+ press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL);
+ assert_eq!(state.mode, ClientShellMode::Prefix);
+}
+
+#[test]
+fn every_field_renders_long_unicode_across_resize_without_mutation() {
+ for field in 0..10 {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::new(&"e\u{301}中👩‍💻".repeat(40), false);
+ for position in [KeyCode::Home, KeyCode::End, KeyCode::Left] {
+ press(&mut state, position, KeyModifiers::NONE);
+ for (width, height) in [(120, 40), (60, 20), (12, 6), (1, 1), (120, 40)] {
+ let before = editor(&mut state).clone();
+ if let Some(frame) = state.compose(width, height) {
+ if let Some(cursor) = frame.cursor.filter(|cursor| cursor.visible) {
+ assert!(cursor.x < width && cursor.y < height, "field {field}");
+ }
+ }
+ assert_eq!(editor(&mut state), &before);
+ }
+ }
+ }
+}
diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs
index 3e19b87c..2a100957 100644
--- a/src/client/shell/tests/workspace_navigation.rs
+++ b/src/client/shell/tests/workspace_navigation.rs
@@ -223,11 +223,12 @@ fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
state.set_pane_surface(pane_surface);
state.compose(100, 28).unwrap();
assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ enter_navigation(&mut state);
+ // Seed the hidden prompt after navigation: text editing consumes the prefix key.
state.copy_mode.as_mut().unwrap().search_prompt = Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
query: "original".into(),
});
- enter_navigation(&mut state);
preview_key(&mut state, b"\x1b[B");
assert!(state.workspace_preview_action_blocked());
assert!(!state.modal_paste_target_active());
@@ -241,7 +242,7 @@ fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
assert!(paste.actions.is_empty() && paste.requests.is_empty());
assert_eq!(
state.copy_mode.unwrap().search_prompt.unwrap().query,
- "original"
+ "original".into()
);
}
diff --git a/src/client/shell/text_editor.rs b/src/client/shell/text_editor.rs
new file mode 100644
index 00000000..c903b919
--- /dev/null
+++ b/src/client/shell/text_editor.rs
@@ -0,0 +1,526 @@
+use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
+use unicode_segmentation::UnicodeSegmentation;
+use unicode_width::UnicodeWidthStr;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub(super) struct TextEditor {
+ text: String,
+ cursor: usize,
+ replace_on_type: bool,
+ killed: String,
+}
+
+impl std::ops::Deref for TextEditor {
+ type Target = str;
+ fn deref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl std::fmt::Display for TextEditor {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.text.fmt(f)
+ }
+}
+
+impl From<&str> for TextEditor {
+ fn from(text: &str) -> Self {
+ Self::new(text, false)
+ }
+}
+
+impl TextEditor {
+ pub fn new(text: &str, replace_on_type: bool) -> Self {
+ let mut editor = Self::default();
+ editor.insert(text);
+ editor.replace_on_type = replace_on_type;
+ editor
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.text
+ }
+
+ pub fn clear(&mut self) {
+ self.text.clear();
+ self.cursor = 0;
+ self.replace_on_type = false;
+ }
+
+ pub fn trim_and_accept(&mut self) {
+ let leading = self.text.len() - self.text.trim_start().len();
+ self.text = self.text.trim().to_owned();
+ self.cursor = self.cursor.saturating_sub(leading).min(self.text.len());
+ self.replace_on_type = false;
+ self.repair_cursor();
+ }
+
+ fn repair_cursor(&mut self) {
+ // Insertion/deletion can join clusters across the edit. Snap forward, never
+ // leave an insertion offset inside the newly formed grapheme.
+ self.cursor = self
+ .text
+ .grapheme_indices(true)
+ .map(|(index, _)| index)
+ .find(|index| *index >= self.cursor)
+ .unwrap_or(self.text.len());
+ }
+
+ pub fn insert(&mut self, text: &str) -> bool {
+ let mut normalized = String::new();
+ let mut chars = text.chars().peekable();
+ while let Some(ch) = chars.next() {
+ match ch {
+ '\r' => {
+ if chars.peek() == Some(&'\n') {
+ chars.next();
+ }
+ normalized.push(' ');
+ }
+ '\n' | '\t' => normalized.push(' '),
+ ch if !ch.is_control() => normalized.push(ch),
+ _ => {}
+ }
+ }
+ if normalized.is_empty() {
+ return false;
+ }
+ let content_changed = !self.replace_on_type || self.text != normalized;
+ if self.replace_on_type {
+ self.clear();
+ }
+ self.text.insert_str(self.cursor, &normalized);
+ self.cursor += normalized.len();
+ self.repair_cursor();
+ content_changed
+ }
+
+ fn previous(&self) -> usize {
+ self.text[..self.cursor]
+ .grapheme_indices(true)
+ .next_back()
+ .map(|(index, _)| index)
+ .unwrap_or(0)
+ }
+
+ fn next(&self) -> usize {
+ self.cursor
+ + self.text[self.cursor..]
+ .graphemes(true)
+ .next()
+ .map(str::len)
+ .unwrap_or(0)
+ }
+
+ fn word_boundary(&self, backward: bool) -> usize {
+ let class = |grapheme: &str| {
+ let ch = grapheme.chars().next().unwrap_or(' ');
+ if ch.is_whitespace() {
+ 0
+ } else if ch.is_alphanumeric() || ch == '_' {
+ 1
+ } else {
+ 2
+ }
+ };
+ let mut boundary = self.cursor;
+ let mut run = 0;
+ if backward {
+ for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() {
+ let current = class(grapheme);
+ if run != 0 && current != run {
+ break;
+ }
+ run = current;
+ boundary = index;
+ }
+ } else {
+ for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) {
+ let current = class(grapheme);
+ if run != 0 && current != run {
+ break;
+ }
+ run = current;
+ boundary = self.cursor + index + grapheme.len();
+ }
+ }
+ boundary
+ }
+
+ fn remove(&mut self, start: usize, end: usize, kill: bool) {
+ self.replace_on_type = false;
+ if start == end {
+ return;
+ }
+ if kill {
+ self.killed = self.text[start..end].to_owned();
+ }
+ self.text.replace_range(start..end, "");
+ self.cursor = start;
+ self.repair_cursor();
+ }
+
+ pub fn handle_key(&mut self, key: &crate::input::TerminalKey) -> Option<bool> {
+ if key.kind == KeyEventKind::Release {
+ return None;
+ }
+ if matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
+ return None;
+ }
+ let previous_len = self.text.len();
+ let mut content_changed = false;
+ let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
+ // Explicit text from the host is authoritative, including AltGr/composition.
+ if let Some(text) = key
+ .generated_text
+ .as_deref()
+ .filter(|text| !text.is_empty())
+ {
+ content_changed = self.insert(text);
+ } else {
+ let ctrl = modifiers == KeyModifiers::CONTROL;
+ let alt = modifiers == KeyModifiers::ALT;
+ let plain = modifiers.is_empty();
+ let movement = match code {
+ KeyCode::Left if plain => Some(self.previous()),
+ KeyCode::Char('b') if ctrl => Some(self.previous()),
+ KeyCode::Right if plain => Some(self.next()),
+ KeyCode::Char('f') if ctrl => Some(self.next()),
+ KeyCode::Home if plain => Some(0),
+ KeyCode::Char('a') if ctrl => Some(0),
+ KeyCode::End if plain => Some(self.text.len()),
+ KeyCode::Char('e') if ctrl => Some(self.text.len()),
+ KeyCode::Char('b') if alt => Some(self.word_boundary(true)),
+ KeyCode::Char('f') if alt => Some(self.word_boundary(false)),
+ _ => None,
+ };
+ if let Some(position) = movement {
+ self.cursor = position;
+ self.replace_on_type = false;
+ } else {
+ match code {
+ KeyCode::Backspace | KeyCode::Char('h')
+ if code == KeyCode::Backspace && plain
+ || code == KeyCode::Char('h') && ctrl =>
+ {
+ if self.replace_on_type {
+ self.clear();
+ } else {
+ self.remove(self.previous(), self.cursor, false);
+ }
+ }
+ KeyCode::Delete if plain => self.remove(self.cursor, self.next(), false),
+ KeyCode::Char('d') if ctrl => self.remove(self.cursor, self.next(), false),
+ KeyCode::Char('u') if ctrl => self.remove(0, self.cursor, true),
+ KeyCode::Char('k') if ctrl => self.remove(self.cursor, self.text.len(), true),
+ KeyCode::Char('w') if ctrl => {
+ self.remove(self.word_boundary(true), self.cursor, true)
+ }
+ KeyCode::Backspace if ctrl || alt => {
+ self.remove(self.word_boundary(true), self.cursor, true)
+ }
+ KeyCode::Char('d') if alt => {
+ self.remove(self.cursor, self.word_boundary(false), true)
+ }
+ KeyCode::Char('y') if ctrl => {
+ content_changed = self.insert(&self.killed.clone());
+ }
+ KeyCode::Char(_) if modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
+ if let Some(ch) = crate::input::keybind_help_text_char(key) {
+ content_changed = self.insert(&ch.to_string());
+ }
+ }
+ _ => return None,
+ }
+ }
+ }
+ Some(content_changed || self.text.len() != previous_len)
+ }
+
+ pub fn viewport(&self, width: u16) -> (&str, u16) {
+ if width == 0 {
+ return ("", 0);
+ }
+ let mut start = self.cursor;
+ let mut cells = 0;
+ for (index, grapheme) in self.text[..self.cursor].grapheme_indices(true).rev() {
+ let next = cells + grapheme.width();
+ if next >= usize::from(width) {
+ break;
+ }
+ start = index;
+ cells = next;
+ }
+ let mut end = self.cursor;
+ let mut used = cells;
+ for (index, grapheme) in self.text[self.cursor..].grapheme_indices(true) {
+ used += grapheme.width();
+ if used > usize::from(width) {
+ break;
+ }
+ end = self.cursor + index + grapheme.len();
+ }
+ (&self.text[start..end], cells as u16)
+ }
+}
+
+pub(super) fn render(
+ buffer: &mut ratatui::buffer::Buffer,
+ area: ratatui::layout::Rect,
+ editor: &TextEditor,
+ style: ratatui::style::Style,
+) -> Option<crate::protocol::CursorState> {
+ let area = area.intersection(buffer.area);
+ if area.is_empty() {
+ return None;
+ }
+ let (text, cursor) = editor.viewport(area.width);
+ for x in area.x..area.right() {
+ buffer[(x, area.y)].set_symbol(" ").set_style(style);
+ }
+ buffer.set_stringn(area.x, area.y, text, usize::from(area.width), style);
+ Some(crate::protocol::CursorState {
+ x: area.x + cursor,
+ y: area.y,
+ visible: true,
+ shape: 0,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::input::TerminalKey;
+
+ fn key(editor: &mut TextEditor, code: KeyCode, modifiers: KeyModifiers) -> bool {
+ let result = editor
+ .handle_key(&TerminalKey::new(code, modifiers))
+ .expect("editor binding");
+ assert!(
+ editor.cursor == editor.len()
+ || editor
+ .grapheme_indices(true)
+ .any(|(i, _)| i == editor.cursor)
+ );
+ result
+ }
+
+ #[test]
+ fn every_binding_edits_at_the_cursor() {
+ use KeyCode::*;
+ let plain = KeyModifiers::NONE;
+ let ctrl = KeyModifiers::CONTROL;
+ let alt = KeyModifiers::ALT;
+ for (code, modifiers, text, cursor, killed) in [
+ (Left, plain, "one two", 3, ""),
+ (Char('b'), ctrl, "one two", 3, ""),
+ (Right, plain, "one two", 5, ""),
+ (Char('f'), ctrl, "one two", 5, ""),
+ (Home, plain, "one two", 0, ""),
+ (Char('a'), ctrl, "one two", 0, ""),
+ (End, plain, "one two", 7, ""),
+ (Char('e'), ctrl, "one two", 7, ""),
+ (Backspace, plain, "onetwo", 3, ""),
+ (Char('h'), ctrl, "onetwo", 3, ""),
+ (Delete, plain, "one wo", 4, ""),
+ (Char('d'), ctrl, "one wo", 4, ""),
+ (Char('b'), alt, "one two", 0, ""),
+ (Char('f'), alt, "one two", 7, ""),
+ (Char('u'), ctrl, "two", 0, "one "),
+ (Char('k'), ctrl, "one ", 4, "two"),
+ (Char('w'), ctrl, "two", 0, "one "),
+ (Backspace, alt, "two", 0, "one "),
+ (Backspace, ctrl, "two", 0, "one "),
+ (Char('d'), alt, "one ", 4, "two"),
+ (Char('y'), ctrl, "one two", 4, ""),
+ (Char('X'), plain, "one Xtwo", 5, ""),
+ ] {
+ let mut editor = TextEditor::from("one two");
+ editor.cursor = 4;
+ let result = key(&mut editor, code, modifiers);
+ assert_eq!(
+ (editor.as_str(), editor.cursor, editor.killed.as_str()),
+ (text, cursor, killed),
+ "{code:?} {modifiers:?}"
+ );
+ assert_eq!(result, text != "one two");
+ let mut empty = TextEditor::default();
+ key(&mut empty, code, modifiers);
+ }
+ }
+
+ #[test]
+ fn suggestions_movement_kills_and_yank() {
+ for (code, expected) in [
+ (KeyCode::Left, "defaulxt"),
+ (KeyCode::Right, "defaultx"),
+ (KeyCode::Home, "xdefault"),
+ (KeyCode::End, "defaultx"),
+ ] {
+ let mut editor = TextEditor::new("default", true);
+ key(&mut editor, code, KeyModifiers::NONE);
+ editor.insert("x");
+ assert_eq!(editor.as_str(), expected);
+ }
+ let mut editor = TextEditor::new("default", true);
+ editor.insert("new");
+ assert_eq!(editor.as_str(), "new");
+ for (replacement, changed) in [("default", false), ("another", true)] {
+ let mut editor = TextEditor::new("default", true);
+ let event = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE)
+ .with_generated_text(Some(replacement.into()));
+ assert_eq!(editor.handle_key(&event).expect("replacement"), changed);
+ assert_eq!(editor.as_str(), replacement);
+ assert!(!editor.replace_on_type);
+ }
+ let mut editor = TextEditor::new("default", true);
+ key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE);
+ assert!(editor.is_empty());
+ let mut editor = TextEditor::new("one two", true);
+ key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL);
+ assert_eq!(editor.as_str(), "one ");
+ assert!(!editor.replace_on_type);
+ key(&mut editor, KeyCode::Char('k'), KeyModifiers::CONTROL);
+ key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE);
+ key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL);
+ key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL);
+ assert_eq!(editor.as_str(), "onetwotwo");
+ key(&mut editor, KeyCode::Char('u'), KeyModifiers::CONTROL);
+ assert_eq!(editor.killed, "onetwotwo");
+ let mut other = TextEditor::default();
+ key(&mut other, KeyCode::Char('y'), KeyModifiers::CONTROL);
+ assert!(other.is_empty());
+ }
+
+ #[test]
+ fn unicode_graphemes_and_boundary_changing_edits() {
+ let mut editor = TextEditor::from("e\u{301}中👩‍💻");
+ for expected in ["e\u{301}中", "e\u{301}", ""] {
+ key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE);
+ assert_eq!(editor.as_str(), expected);
+ }
+ let mut editor = TextEditor::from("👩💻");
+ key(&mut editor, KeyCode::Left, KeyModifiers::NONE);
+ editor.insert("\u{200d}");
+ assert_eq!(editor.cursor, editor.len());
+ key(&mut editor, KeyCode::Backspace, KeyModifiers::NONE);
+ assert!(editor.is_empty());
+ let mut editor = TextEditor::from("\u{301}x");
+ key(&mut editor, KeyCode::Home, KeyModifiers::NONE);
+ editor.insert("e");
+ assert_eq!(editor.cursor, "e\u{301}".len());
+ key(&mut editor, KeyCode::Delete, KeyModifiers::NONE);
+ assert_eq!(editor.as_str(), "e\u{301}");
+ }
+
+ #[test]
+ fn accepting_trimmed_branch_preserves_cursor_and_local_kill_buffer() {
+ let mut editor = TextEditor::new(" feature/name ", true);
+ editor.trim_and_accept();
+ assert_eq!(editor.as_str(), "feature/name");
+ assert!(!editor.replace_on_type);
+ assert_eq!(editor.cursor, editor.len());
+ key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL);
+ editor.trim_and_accept();
+ key(&mut editor, KeyCode::Char('y'), KeyModifiers::CONTROL);
+ assert_eq!(editor.as_str(), "feature/name");
+ }
+
+ #[test]
+ fn words_distinguish_paths_punctuation_and_whitespace() {
+ let mut editor = TextEditor::from("src/foo_bar.rs e\u{301}中 👩‍💻");
+ for expected in [
+ "src/foo_bar.rs e\u{301}中 ",
+ "src/foo_bar.rs ",
+ "src/foo_bar.",
+ "src/foo_bar",
+ "src/",
+ "src",
+ "",
+ ] {
+ key(&mut editor, KeyCode::Char('w'), KeyModifiers::CONTROL);
+ assert_eq!(editor.as_str(), expected);
+ }
+ let mut editor = TextEditor::from(" /tmp/foo_bar.rs");
+ key(&mut editor, KeyCode::Home, KeyModifiers::NONE);
+ for expected in [2, 5, 6, 13, 14, 16] {
+ key(&mut editor, KeyCode::Char('f'), KeyModifiers::ALT);
+ assert_eq!(editor.cursor, expected);
+ }
+ }
+
+ #[test]
+ fn insertion_normalizes_controls_and_respects_host_text() {
+ let mut editor = TextEditor::from("ab");
+ key(&mut editor, KeyCode::Left, KeyModifiers::NONE);
+ editor.insert("中\r\n\r\n\t\x00\x1b\u{7f}e\u{301}");
+ assert_eq!(editor.as_str(), "a中 e\u{301}b");
+ let event = TerminalKey::new(
+ KeyCode::Char('b'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ )
+ .with_generated_text(Some("β".into()));
+ editor.handle_key(&event);
+ assert_eq!(editor.as_str(), "a中 e\u{301}βb");
+ let before = editor.clone();
+ assert!(editor
+ .handle_key(&event.with_kind(KeyEventKind::Release))
+ .is_none());
+ assert_eq!(editor, before);
+ let repeat =
+ TerminalKey::new(KeyCode::Left, KeyModifiers::NONE).with_kind(KeyEventKind::Repeat);
+ assert!(editor.handle_key(&repeat).is_some());
+ }
+
+ #[test]
+ fn enter_and_escape_ignore_generated_text() {
+ for code in [KeyCode::Enter, KeyCode::Esc] {
+ let mut editor = TextEditor::new("default", true);
+ let before = editor.clone();
+ let event = TerminalKey::new(code, KeyModifiers::NONE)
+ .with_generated_text(Some("printable".into()));
+ assert_eq!(editor.handle_key(&event), None, "{code:?}");
+ assert_eq!(editor, before, "{code:?}");
+ }
+ }
+
+ #[test]
+ fn viewport_and_render_are_pure_and_grapheme_safe() {
+ use ratatui::{buffer::Buffer, layout::Rect, style::Style};
+ for text in [
+ "abcdefghijklmnopqrstuvwxyz",
+ "e\u{301}中👩‍💻xyz",
+ "\u{301}abc",
+ ] {
+ let mut editor = TextEditor::from(text);
+ for cursor in text
+ .grapheme_indices(true)
+ .map(|(i, _)| i)
+ .chain([text.len()])
+ {
+ editor.cursor = cursor;
+ for width in [0, 1, 2, 3, 8, 80] {
+ let before = editor.clone();
+ let (visible, col) = editor.viewport(width);
+ assert!(visible.width() <= usize::from(width));
+ assert!(width == 0 || col < width);
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 80, 1));
+ let result = render(
+ &mut buffer,
+ Rect::new(0, 0, width, 1),
+ &editor,
+ Style::default(),
+ );
+ assert_eq!(result.map(|c| c.x), (width > 0).then_some(col));
+ assert_eq!(editor, before);
+ }
+ }
+ }
+ let mut editor = TextEditor::from("abcdef");
+ assert_eq!(editor.viewport(4), ("def", 3));
+ editor.cursor = 2;
+ assert_eq!(editor.viewport(4), ("abcd", 2));
+ editor.cursor = 0;
+ assert_eq!(editor.viewport(4), ("abcd", 0));
+ }
+}
diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs
index f4b9d611..318c4019 100644
--- a/src/client/shell/worktree_overlays.rs
+++ b/src/client/shell/worktree_overlays.rs
@@ -28,12 +28,10 @@ pub(super) fn render_worktree_create_overlay(
);
let input = Rect::new(inner.x, inner.y + 3, inner.width, 1);
b.set_style(input, Style::default().fg(p.text).bg(p.surface0));
- put_text(
+ let cursor = text_editor::render(
b,
- input.x,
- input.y,
- input.width,
- &format!(" {}", create.branch),
+ Rect::new(input.x + 1, input.y, input.width.saturating_sub(1), 1),
+ &create.branch,
Style::default().fg(p.text).bg(p.surface0),
);
put_text(
@@ -103,12 +101,7 @@ pub(super) fn render_worktree_create_overlay(
navigator_rows: Vec::new(),
worktree_search: Rect::default(),
worktree_rows: Vec::new(),
- cursor: (!create.creating).then(|| crate::protocol::CursorState {
- x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1),
- y: input.y,
- visible: true,
- shape: 0,
- }),
+ cursor: cursor.filter(|_| !create.creating),
..OverlayRender::default()
})
}
@@ -139,7 +132,9 @@ pub(super) fn render_worktree_open_overlay(
search.x,
search.y,
search.width,
- &if open.search_focused || !open.query.is_empty() {
+ &if open.search_focused {
+ " / ".to_owned()
+ } else if !open.query.is_empty() {
format!(" / {}", open.query)
} else {
" / filter worktrees".to_owned()
@@ -157,6 +152,21 @@ pub(super) fn render_worktree_open_overlay(
} else {
format!("{}/{} checkouts", filtered.len(), open.entries.len())
};
+ let cursor = if open.search_focused {
+ text_editor::render(
+ b,
+ Rect::new(
+ search.x + 3,
+ search.y,
+ search.width.saturating_sub(4 + display_width(&count)),
+ 1,
+ ),
+ &open.query,
+ Style::default().fg(p.text).bg(p.panel_bg),
+ )
+ } else {
+ None
+ };
put_right_text(
b,
search,
@@ -290,12 +300,7 @@ pub(super) fn render_worktree_open_overlay(
navigator_rows: Vec::new(),
worktree_search: search,
worktree_rows: row_hits,
- cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState {
- x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1),
- y: search.y,
- visible: true,
- shape: 0,
- }),
+ cursor: cursor.filter(|_| !open.opening),
..OverlayRender::default()
})
}
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
index d4253672..56c554c8 100644
--- a/src/client/shell/worktrees.rs
+++ b/src/client/shell/worktrees.rs
@@ -24,20 +24,18 @@ impl ClientShellState {
pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool {
match self.overlay.as_mut() {
Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => {
- if create.replace_on_type {
- create.branch.clear();
- create.replace_on_type = false;
+ if create.branch.insert(text) {
+ self.sync_worktree_create_path();
}
- create.branch.push_str(text);
- self.sync_worktree_create_path();
true
}
Some(ClientShellOverlay::WorktreeOpen(open))
if open.search_focused && !open.opening =>
{
- open.query.push_str(text);
- if let Some(first) = open.filtered_indices().first().copied() {
- open.selected = first;
+ if open.query.insert(text) {
+ if let Some(first) = open.filtered_indices().first().copied() {
+ open.selected = first;
+ }
}
true
}
@@ -59,39 +57,24 @@ impl ClientShellState {
ClientWorktreeCreateOverlay { creating: true, .. }
))
);
+ if !creating {
+ if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut()
+ {
+ if let Some(content_changed) = create.branch.handle_key(key) {
+ if content_changed {
+ self.sync_worktree_create_path();
+ }
+ outcome.repaint = true;
+ return true;
+ }
+ }
+ }
match code {
KeyCode::Esc if !creating => {
self.overlay = None;
outcome.repaint = true;
}
KeyCode::Enter => self.submit_worktree_create(outcome),
- KeyCode::Backspace if !creating => {
- if let Some(ClientShellOverlay::WorktreeCreate(create)) =
- self.overlay.as_mut()
- {
- if create.replace_on_type {
- create.branch.clear();
- create.replace_on_type = false;
- } else {
- create.branch.pop();
- }
- }
- self.sync_worktree_create_path();
- outcome.repaint = true;
- }
- KeyCode::Char(character)
- if !creating
- && modifiers
- .difference(crossterm::event::KeyModifiers::SHIFT)
- .is_empty() =>
- {
- let text = key
- .generated_text
- .clone()
- .unwrap_or_else(|| character.to_string());
- self.insert_worktree_overlay_text(&text);
- outcome.repaint = true;
- }
_ => {}
}
true
@@ -112,6 +95,19 @@ impl ClientShellState {
}
))
);
+ if !opening && search_focused {
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() {
+ if let Some(content_changed) = open.query.handle_key(key) {
+ if content_changed {
+ if let Some(first) = open.filtered_indices().first().copied() {
+ open.selected = first;
+ }
+ }
+ outcome.repaint = true;
+ return true;
+ }
+ }
+ }
match code {
KeyCode::Esc if !opening => {
self.overlay = None;
@@ -126,37 +122,23 @@ impl ClientShellState {
self.move_worktree_open_selection(1);
outcome.repaint = true;
}
- KeyCode::Char('/') if !opening && !search_focused => {
- if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut()
- {
- open.search_focused = true;
- }
+ KeyCode::Char('n' | 'p')
+ if !opening && modifiers == crossterm::event::KeyModifiers::CONTROL =>
+ {
+ self.move_worktree_open_selection(if code == KeyCode::Char('n') {
+ 1
+ } else {
+ -1
+ });
outcome.repaint = true;
}
- KeyCode::Backspace if !opening && search_focused => {
+ KeyCode::Char('/') if !opening && !search_focused => {
if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut()
{
- open.query.pop();
- if let Some(first) = open.filtered_indices().first().copied() {
- open.selected = first;
- }
+ open.search_focused = true;
}
outcome.repaint = true;
}
- KeyCode::Char(character)
- if !opening
- && search_focused
- && modifiers
- .difference(crossterm::event::KeyModifiers::SHIFT)
- .is_empty() =>
- {
- let text = key
- .generated_text
- .clone()
- .unwrap_or_else(|| character.to_string());
- self.insert_worktree_overlay_text(&text);
- outcome.repaint = true;
- }
_ => {}
}
true
@@ -274,8 +256,7 @@ impl ClientShellState {
outcome.repaint = true;
return;
}
- create.branch = branch.clone();
- create.replace_on_type = false;
+ create.branch.trim_and_accept();
create.checkout_path =
checkout_path_preview(&worktree_directory, &create.repo_name, &branch);
create.creating = true;
@@ -411,9 +392,8 @@ impl ClientShellState {
ClientWorktreeCreateOverlay {
source_workspace_id: workspace_id,
repo_name: source.repo_name,
- branch,
+ branch: TextEditor::new(&branch, true),
checkout_path,
- replace_on_type: true,
error: None,
creating: false,
},
@@ -447,7 +427,7 @@ impl ClientShellState {
source_workspace_id: workspace_id,
entries,
selected: 0,
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
error: None,
opening: false,