Skip to content

Commit a1eeff9

Browse files
warp-agent-staging[bot]oz-agentwarp-agentharryalbert
authored
[APP-5057] Add Cmd editor shortcuts to the TUI (#14517)
## Description Adds familiar Cmd-based editing shortcuts to the shared TUI editor binding set, covering both the agent input and reusable TUI editor surfaces without changing any existing binding: - Cmd+A/C/V/X/Z and Cmd+Shift+Z for select all, copy, OS-clipboard paste, cut, undo, and redo. - Cmd+Left/Right and Cmd+Shift+Left/Right for visual-line movement and selection. - Cmd+Backspace for delete-to-line-start. - A TUI-specific binding-validator allowance so Super/Cmd chords work on every OS where enhanced terminal keyboard reporting provides them, without changing GUI validation or behavior. Cmd+Delete remains intentionally unbound because terminal input cannot represent it portably (an existing regression test enforces this). The optional buffer-top/bottom and clear-lines chords remain out of scope because the shared TUI editor has no corresponding commands. No proposed must-have chord collided with an existing TUI binding; all legacy Ctrl/Alt/Home/End bindings remain registered. Originating thread: https://warpdev.slack.com/archives/C0BDQDW8V5E/p1785370335034379 ## Linked Issue - [x] [APP-5057](https://linear.app/warpdotdev/issue/APP-5057/tui-editor-add-gui-editor-cmd-based-hotkeys-cmdacvxz-etc-now-that-tui) - [x] The ticket is gated for this spec-skipped implementation. - [x] Visual proof is included below. ## Testing - Fail-before: `shared_editor_registers_additive_cmd_bindings` failed because `cmd-a` was absent. - Focused regression suite: 5/5 passed (binding registration, paste insertion/empty/dispatch, and line selection). - `env -u WARP_API_KEY CARGO_BUILD_JOBS=2 cargo nextest run -p warp_tui`: 852/852 passed after merging current `master`. - `CARGO_INCREMENTAL=0 cargo clippy -p warp_tui --all-targets --tests -- -D warnings`: passed after merging current `master`. - `CARGO_BUILD_JOBS=2 cargo build -p warp_tui --bin warp-tui-oss`: passed. - `./script/format` and `git diff --check`: passed. - `./script/presubmit`: formatting and all documented Clippy phases passed; the workspace test phase could not complete in this cloud runner because unrelated environment tests failed (root-only sudo expectation, external harness HTTP 501 responses, and missing XDG runtime), after an initial run also exhausted the sandbox build-artifact quota. The touched-crate suite above passed independently. - Live authenticated TUI: enhanced-keyboard Cmd+A selected `cmd hotkey roundtrip`, Cmd+X cut it to the OS clipboard, and Cmd+V restored it; the recording also exercises Cmd+Z and Cmd+Shift+Z. - [x] I manually tested the running TUI with `warp-tui-oss`. ### Screenshots / Videos [View the implementation run, screenshot, and video artifacts](https://staging.warp.dev/conversation/8b718d0c-e590-4397-9bc6-b24c01883f34). ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode CHANGELOG-IMPROVEMENT: Added familiar Cmd-based editing shortcuts to Warp Agent CLI input. Co-Authored-By: Warp <agent@warp.dev> Co-Authored-By: Oz <oz-agent@warp.dev> ### Rework changes - Added `cmd_bindings_dispatch_expected_editor_commands`, which dispatches Cmd+A/C/V/X/Z and Cmd+Shift+Z through the registered keymap and verifies that each chord produces its intended `TuiEditorCommand`. - The standalone Cmd+C visual-proof finding was explicitly waived by the requester in the originating Slack thread; the existing proof is unchanged. - Revalidated with `./script/format`, focused Clippy, and all 852 `warp_tui` tests after merging the current `master`. <!-- factory-agent: {"source":"factory-agent","task_id":"APP-5057","task_source":"linear","task_url":"https://linear.app/warpdotdev/issue/APP-5057/tui-editor-add-gui-editor-cmd-based-hotkeys-cmdacvxz-etc-now-that-tui","linear_issue_id":"APP-5057","oz_run_id":"019fb066-9291-7c2c-80f7-d4736ecfbf7f","repo":"warpdotdev/warp","review_rework_attempts":1} --> --------- Co-authored-by: Oz <oz-agent@warp.dev> Co-authored-by: Warp <agent@warp.dev> Co-authored-by: harryalbert <harryalbert364@gmail.com>
1 parent 6de2388 commit a1eeff9

7 files changed

Lines changed: 427 additions & 21 deletions

File tree

crates/warp_tui/src/clipboard.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ fn set_native_text(text: &str) -> anyhow::Result<()> {
9090
anyhow::bail!("native OS clipboard is not supported on this platform")
9191
}
9292

93+
/// Reads the OS clipboard's plain text for a local paste (`cmd-v`).
94+
///
95+
/// This reuses the shared native clipboard backend (`arboard` via
96+
/// `warpui::platform::create_system_clipboard`). Unlike [`copy_to_clipboard`],
97+
/// there is no OSC 52 fallback: terminals do not reliably answer OSC 52 read
98+
/// requests, so a remote/SSH `cmd-v` still relies on the terminal's own
99+
/// bracketed-paste path (`TuiEditorAction::PasteText`). Returns an error only
100+
/// when the native backend is unavailable.
101+
pub(crate) fn read_from_clipboard() -> anyhow::Result<String> {
102+
let mut clipboard = warpui::platform::create_system_clipboard()
103+
.map_err(|error| error.context("native OS clipboard is unavailable"))?;
104+
Ok(clipboard.read().plain_text)
105+
}
106+
93107
fn write_osc52_sequences(text: &str, in_tmux: bool, writer: &mut impl Write) -> io::Result<()> {
94108
let sequence = osc52_sequences(text, in_tmux);
95109
writer.write_all(sequence.as_bytes())?;

crates/warp_tui/src/editor_interaction.rs

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use warp_editor::selection::{TextDirection, TextUnit};
88
use warpui_core::text::word_boundaries::WordBoundariesPolicy;
99
use warpui_core::{AppContext, ModelHandle};
1010

11-
use crate::clipboard::copy_to_clipboard;
11+
use crate::clipboard::{copy_to_clipboard, read_from_clipboard};
1212
use crate::editor_element::TuiEditorAction;
1313

1414
/// Editing commands shared by TUI text fields.
@@ -33,9 +33,12 @@ pub enum TuiEditorCommand {
3333
SelectDown,
3434
SelectWordLeft,
3535
SelectWordRight,
36+
SelectToLineStart,
37+
SelectToLineEnd,
3638
SelectAll,
3739
Copy,
3840
Cut,
41+
Paste,
3942
KillToLineEnd,
4043
KillToLineStart,
4144
Yank,
@@ -120,6 +123,8 @@ pub(crate) enum TuiEditorInteractionOutcome {
120123
FollowCursor,
121124
PreserveViewport,
122125
Clipboard(TuiEditorClipboardAction),
126+
/// Insert the OS clipboard's text at the cursor (`cmd-v` / paste).
127+
Paste,
123128
}
124129

125130
/// Clipboard operation requested by a shared TUI editor command.
@@ -236,14 +241,14 @@ const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[
236241
input_name: Some("tui:input:move_to_line_start"),
237242
editor_name: Some("tui:editor:move_to_line_start"),
238243
description: "Move cursor to start of line",
239-
keys: &["home", "ctrl-a"],
244+
keys: &["home", "ctrl-a", "cmd-left"],
240245
},
241246
EditorBindingSpec {
242247
command: TuiEditorCommand::MoveToLineEnd,
243248
input_name: Some("tui:input:move_to_line_end"),
244249
editor_name: Some("tui:editor:move_to_line_end"),
245250
description: "Move cursor to end of line",
246-
keys: &["end", "ctrl-e"],
251+
keys: &["end", "ctrl-e", "cmd-right"],
247252
},
248253
EditorBindingSpec {
249254
command: TuiEditorCommand::SelectLeft,
@@ -287,26 +292,47 @@ const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[
287292
description: "Extend selection one word right",
288293
keys: &["ctrl-shift-right", "alt-shift-right"],
289294
},
295+
EditorBindingSpec {
296+
command: TuiEditorCommand::SelectToLineStart,
297+
input_name: Some("tui:input:select_to_line_start"),
298+
editor_name: Some("tui:editor:select_to_line_start"),
299+
description: "Extend selection to start of line",
300+
keys: &["cmd-shift-left"],
301+
},
302+
EditorBindingSpec {
303+
command: TuiEditorCommand::SelectToLineEnd,
304+
input_name: Some("tui:input:select_to_line_end"),
305+
editor_name: Some("tui:editor:select_to_line_end"),
306+
description: "Extend selection to end of line",
307+
keys: &["cmd-shift-right"],
308+
},
290309
EditorBindingSpec {
291310
command: TuiEditorCommand::SelectAll,
292311
input_name: Some("tui:input:select_all"),
293312
editor_name: Some("tui:editor:select_all"),
294313
description: "Select all text",
295-
keys: &["ctrl-shift-A"],
314+
keys: &["ctrl-shift-A", "cmd-a"],
296315
},
297316
EditorBindingSpec {
298317
command: TuiEditorCommand::Copy,
299318
input_name: Some("tui:input:copy"),
300319
editor_name: Some("tui:editor:copy"),
301320
description: "Copy selected text",
302-
keys: &["ctrl-shift-C", "alt-w"],
321+
keys: &["ctrl-shift-C", "alt-w", "cmd-c"],
303322
},
304323
EditorBindingSpec {
305324
command: TuiEditorCommand::Cut,
306325
input_name: Some("tui:input:cut"),
307326
editor_name: Some("tui:editor:cut"),
308327
description: "Cut selected text",
309-
keys: &["ctrl-x"],
328+
keys: &["ctrl-x", "cmd-x"],
329+
},
330+
EditorBindingSpec {
331+
command: TuiEditorCommand::Paste,
332+
input_name: Some("tui:input:paste"),
333+
editor_name: Some("tui:editor:paste"),
334+
description: "Paste text from the clipboard",
335+
keys: &["cmd-v"],
310336
},
311337
EditorBindingSpec {
312338
command: TuiEditorCommand::KillToLineEnd,
@@ -320,7 +346,7 @@ const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[
320346
input_name: Some("tui:input:kill_to_line_start"),
321347
editor_name: Some("tui:editor:kill_to_line_start"),
322348
description: "Delete to start of line",
323-
keys: &["ctrl-u"],
349+
keys: &["ctrl-u", "cmd-backspace"],
324350
},
325351
EditorBindingSpec {
326352
command: TuiEditorCommand::Yank,
@@ -334,14 +360,14 @@ const SHARED_EDITOR_BINDINGS: &[EditorBindingSpec] = &[
334360
input_name: Some("tui:input:undo"),
335361
editor_name: Some("tui:editor:undo"),
336362
description: "Undo",
337-
keys: &["ctrl-z"],
363+
keys: &["ctrl-z", "cmd-z"],
338364
},
339365
EditorBindingSpec {
340366
command: TuiEditorCommand::Redo,
341367
input_name: Some("tui:input:redo"),
342368
editor_name: Some("tui:editor:redo"),
343369
description: "Redo",
344-
keys: &["ctrl-shift-Z"],
370+
keys: &["ctrl-shift-Z", "cmd-shift-Z"],
345371
},
346372
];
347373

@@ -478,6 +504,12 @@ impl TuiEditorState {
478504
);
479505
});
480506
}
507+
TuiEditorCommand::SelectToLineStart => {
508+
model.update(ctx, |model, ctx| model.select_to_line_start(ctx));
509+
}
510+
TuiEditorCommand::SelectToLineEnd => {
511+
model.update(ctx, |model, ctx| model.select_to_line_end(ctx));
512+
}
481513
TuiEditorCommand::SelectAll => {
482514
model.update(ctx, |model, ctx| model.select_all(ctx));
483515
}
@@ -487,6 +519,9 @@ impl TuiEditorState {
487519
TuiEditorCommand::Cut => {
488520
return TuiEditorInteractionOutcome::Clipboard(TuiEditorClipboardAction::Cut);
489521
}
522+
TuiEditorCommand::Paste => {
523+
return TuiEditorInteractionOutcome::Paste;
524+
}
490525
TuiEditorCommand::KillToLineEnd => {
491526
if let Some(killed) = model.update(ctx, |model, ctx| {
492527
model.kill_to_char_cell_visual_row_end(ctx)
@@ -559,6 +594,44 @@ pub(crate) fn apply_editor_clipboard_action_for_test(
559594
) -> anyhow::Result<bool> {
560595
apply_editor_clipboard_action_with(model, action, copy, ctx)
561596
}
597+
598+
/// Reads the OS clipboard and inserts its text at the cursor, applying the
599+
/// editor's line policy. Returns `false` when the clipboard has no text.
600+
pub(crate) fn apply_editor_paste(
601+
model: &ModelHandle<CodeEditorModel>,
602+
behavior: TuiEditorBehavior,
603+
ctx: &mut AppContext,
604+
) -> anyhow::Result<bool> {
605+
apply_editor_paste_with(model, read_from_clipboard, behavior, ctx)
606+
}
607+
608+
fn apply_editor_paste_with(
609+
model: &ModelHandle<CodeEditorModel>,
610+
read: impl FnOnce() -> anyhow::Result<String>,
611+
behavior: TuiEditorBehavior,
612+
ctx: &mut AppContext,
613+
) -> anyhow::Result<bool> {
614+
let text = read()?;
615+
if text.is_empty() {
616+
return Ok(false);
617+
}
618+
let text = behavior.normalize_text(&text).to_owned();
619+
if text.is_empty() {
620+
return Ok(false);
621+
}
622+
model.update(ctx, |model, ctx| model.user_insert(&text, ctx));
623+
Ok(true)
624+
}
625+
626+
#[cfg(test)]
627+
pub(crate) fn apply_editor_paste_for_test(
628+
model: &ModelHandle<CodeEditorModel>,
629+
read: impl FnOnce() -> anyhow::Result<String>,
630+
behavior: TuiEditorBehavior,
631+
ctx: &mut AppContext,
632+
) -> anyhow::Result<bool> {
633+
apply_editor_paste_with(model, read, behavior, ctx)
634+
}
562635
/// Applies an element-originated action and reports the required viewport work.
563636
pub(crate) fn apply_editor_action(
564637
model: &ModelHandle<CodeEditorModel>,

crates/warp_tui/src/editor_view.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use warpui_core::{
1818
use crate::editor_element::{TuiEditorAction, TuiEditorElement};
1919
use crate::editor_interaction::{
2020
TuiEditorBehavior, TuiEditorCommand, TuiEditorInteractionOutcome, TuiEditorState,
21-
apply_editor_action, apply_editor_clipboard_action, follow_editor_cursor,
21+
apply_editor_action, apply_editor_clipboard_action, apply_editor_paste, follow_editor_cursor,
2222
};
2323

2424
#[derive(Clone, Copy)]
@@ -238,6 +238,12 @@ impl TypedActionView for TuiEditorView {
238238
}
239239
TuiEditorInteractionOutcome::FollowCursor
240240
}
241+
TuiEditorInteractionOutcome::Paste => {
242+
if let Err(error) = apply_editor_paste(&self.model, self.editor_behavior, ctx) {
243+
log::error!("Failed to paste into TUI editor: {error}");
244+
}
245+
TuiEditorInteractionOutcome::FollowCursor
246+
}
241247
outcome => outcome,
242248
};
243249
if outcome == TuiEditorInteractionOutcome::FollowCursor {

0 commit comments

Comments
 (0)