mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
feat(terminal): dock a message composer under agent panes
A multi-line text box docked at the bottom of a pane whose foreground is a coding agent, for writing a prompt with the platform's own editing keys, mouse selection and an in-place IME, then handing it over whole. Toggle with Cmd+I (Ctrl+Shift+I off macOS) or "Toggle Message Composer" in the palette. - Docks below the grid instead of floating over it, so the agent reflows into the remaining rows and nothing it draws is hidden. - Enter sends, Shift+Enter inserts a newline, Esc hands focus back to the terminal without closing (so the next Esc still interrupts the agent). - A message is written as text, then Enter as a separate write after a short settle. Multi-line text goes as one bracketed paste; Codex is always pasted (its burst detector swallows the Enter otherwise); a leading `!` reaches Claude Code as its own key so it switches to shell mode. - Nothing is sent while the agent is waiting on a permission prompt or question; the box turns amber and keeps the message. - Writes queue per pane and stop if the agent exits mid-send, so an Enter can never land in the shell. - Pasted screenshots, copied files and dropped files reuse the terminal's existing path-pasting routes and land in the box when it has focus. - Drafts and the open state survive the view being rebuilt. Refs #842
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
//! The message composer: a real text box docked under a pane that is running a
|
||||
//! coding agent, for writing a prompt the way a chat box lets you — mouse
|
||||
//! selection, the platform's own editing keys, an IME with its candidates in
|
||||
//! place, pasting a screenshot — and handing it over whole.
|
||||
//!
|
||||
//! The agent's own TUI stays exactly where it was. The box docks *below* the
|
||||
//! grid rather than floating over it, so the grid gives up the rows the box
|
||||
//! takes and the agent reflows into what is left: nothing it draws is hidden,
|
||||
//! including the permission prompts that have to be answered in the TUI itself.
|
||||
//!
|
||||
//! Sending is typing, not an API. What leaves the box is written to the pane's
|
||||
//! pty as the text and then Enter, as two writes — see [`submit_plan`] for why
|
||||
//! each agent needs to be spoken to slightly differently.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::Duration;
|
||||
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use gpui::{
|
||||
Context, Entity, Focusable as _, MouseButton, MouseDownEvent, Subscription, Window, div,
|
||||
prelude::*, px,
|
||||
};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{ActiveTheme as _, h_flex};
|
||||
|
||||
use super::view::{TerminalView, types_cleanly};
|
||||
use crate::core::cli_agent::{AgentStatus, CLIAgent};
|
||||
use crate::ui::host_ops::HostId;
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
|
||||
/// How tall the box may grow, in lines of text, before it scrolls instead.
|
||||
/// Every row it grows is a row the agent loses, and a resize the agent has to
|
||||
/// redraw for, so this stays well short of a page.
|
||||
const MAX_ROWS: usize = 8;
|
||||
|
||||
/// The pause between two writes that must not arrive as one read.
|
||||
///
|
||||
/// An agent's input layer tells typing from pasting by how bytes arrive, and a
|
||||
/// CR landing in the same read as the text before it is taken as part of that
|
||||
/// text — a newline in the message rather than the key that sends it. Long
|
||||
/// enough to put the two in separate reads on a loaded machine, short enough
|
||||
/// to be inside the time a key press takes to feel instant.
|
||||
const SETTLE: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Copilot's input treats a CR that follows a paste too closely as part of it.
|
||||
const SETTLE_AFTER_PASTE_SLOW: Duration = Duration::from_millis(300);
|
||||
|
||||
/// One write of a submission, and how long to wait before making it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct Step {
|
||||
pub delay: Duration,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The writes that hand `text` to `agent` and press Enter on it.
|
||||
///
|
||||
/// - **The text and the Enter are separate writes**, [`SETTLE`] apart. In one
|
||||
/// write, the CR is part of the text as far as the agent can tell.
|
||||
/// - **A single plain line is typed**, not pasted: it goes in the way the
|
||||
/// keyboard would have sent it, so the agent treats it like typing — a `/`
|
||||
/// command opens its menu, nothing is folded into a "pasted text"
|
||||
/// placeholder. Anything with a line break, a tab or other control
|
||||
/// character, or past [`types_cleanly`]'s bound goes as one bracketed paste,
|
||||
/// so the lines are the message's rather than a series of Enters. That is
|
||||
/// the same line the prompt editor draws for the shell.
|
||||
/// - **Codex is always pasted.** It watches for bursts of fast keystrokes to
|
||||
/// spot pastes from terminals that do not bracket them, and the Enter after
|
||||
/// a typed burst is swallowed into it.
|
||||
/// - **A leading `!` goes to Claude Code on its own.** It switches the input
|
||||
/// into shell mode only when it is typed into an empty box as a key of its
|
||||
/// own; arriving with the rest of the line it is just a character.
|
||||
///
|
||||
/// Without bracketed paste switched on, line breaks go as LF: to every agent
|
||||
/// input that is Ctrl+J, a newline in the message, where a CR would send each
|
||||
/// line as a message of its own.
|
||||
pub(super) fn submit_plan(agent: CLIAgent, text: &str, bracketed: bool) -> Vec<Step> {
|
||||
let clean: String = text
|
||||
.replace("\r\n", "\n")
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1b')
|
||||
.map(|c| if c == '\r' { '\n' } else { c })
|
||||
.collect();
|
||||
let mut body = clean.trim_end();
|
||||
let mut steps = Vec::new();
|
||||
let mut delay = Duration::ZERO;
|
||||
|
||||
if agent == CLIAgent::Claude
|
||||
&& let Some(rest) = body.strip_prefix('!')
|
||||
&& !rest.is_empty()
|
||||
{
|
||||
steps.push(Step {
|
||||
delay,
|
||||
bytes: b"!".to_vec(),
|
||||
});
|
||||
body = rest;
|
||||
delay = SETTLE;
|
||||
}
|
||||
|
||||
let mut pasted = false;
|
||||
if !body.is_empty() {
|
||||
pasted = bracketed && (agent == CLIAgent::Codex || !types_cleanly(body));
|
||||
let bytes = match pasted {
|
||||
true => tty7_core::core::paste::bracket(body.as_bytes()),
|
||||
false => body.as_bytes().to_vec(),
|
||||
};
|
||||
steps.push(Step { delay, bytes });
|
||||
}
|
||||
|
||||
let enter_delay = match (steps.is_empty(), agent) {
|
||||
(true, _) => Duration::ZERO,
|
||||
(false, CLIAgent::Copilot) if pasted => SETTLE_AFTER_PASTE_SLOW,
|
||||
(false, _) => SETTLE,
|
||||
};
|
||||
steps.push(Step {
|
||||
delay: enter_delay,
|
||||
bytes: b"\r".to_vec(),
|
||||
});
|
||||
steps
|
||||
}
|
||||
|
||||
/// What a pane's composer holds that outlives the view drawing it.
|
||||
///
|
||||
/// A view is rebuilt over the same daemon pane whenever its workspace is
|
||||
/// switched out and back, and a half-written prompt must not be the price of
|
||||
/// looking at another workspace. Kept by pane, for the life of the app.
|
||||
#[derive(Default)]
|
||||
struct ComposerMemory(HashMap<(HostId, u64), Remembered>);
|
||||
|
||||
impl gpui::Global for ComposerMemory {}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Remembered {
|
||||
draft: String,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Composer {
|
||||
pub(super) input: Entity<InputState>,
|
||||
/// Whether the user wants the box on this pane. It is only *shown* while
|
||||
/// an agent is in the foreground, so a pane that goes back to its shell
|
||||
/// hides it and the next agent started there gets it back.
|
||||
open: bool,
|
||||
/// Writes waiting their turn. Submissions queue rather than interleave, so
|
||||
/// a second message sent inside the first one's settle time cannot land
|
||||
/// its text between the first one's text and its Enter.
|
||||
queue: VecDeque<Step>,
|
||||
pumping: bool,
|
||||
/// Text for the box from paths that cannot reach it directly — a paste
|
||||
/// resolved on a background task, a file upload — taken in at the next
|
||||
/// draw, which is the first place with a window to edit it in.
|
||||
pending: Vec<String>,
|
||||
/// Whose name the placeholder carries. A pane can run one agent after
|
||||
/// another, and "Message Codex…" over Claude Code would be a lie.
|
||||
named: Option<CLIAgent>,
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
fn composer_key(&self) -> (HostId, u64) {
|
||||
(self.host_id(), self.pane_id)
|
||||
}
|
||||
|
||||
fn remember_composer(&self, cx: &mut Context<Self>) {
|
||||
let Some(c) = self.composer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let entry = Remembered {
|
||||
draft: c.input.read(cx).value().to_string(),
|
||||
open: c.open,
|
||||
};
|
||||
let key = self.composer_key();
|
||||
let memory = cx.default_global::<ComposerMemory>();
|
||||
match entry.draft.is_empty() && !entry.open {
|
||||
true => memory.0.remove(&key),
|
||||
false => memory.0.insert(key, entry),
|
||||
};
|
||||
}
|
||||
|
||||
fn ensure_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.composer.is_some() {
|
||||
return;
|
||||
}
|
||||
let remembered = cx
|
||||
.try_global::<ComposerMemory>()
|
||||
.and_then(|m| m.0.get(&self.composer_key()))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.multi_line(true)
|
||||
.auto_grow(1, MAX_ROWS)
|
||||
.submit_on_enter(true)
|
||||
.default_value(remembered.draft)
|
||||
});
|
||||
let subs = vec![cx.subscribe_in(&input, window, Self::on_composer_event)];
|
||||
self.composer = Some(Composer {
|
||||
input,
|
||||
open: remembered.open,
|
||||
queue: VecDeque::new(),
|
||||
pumping: false,
|
||||
pending: Vec::new(),
|
||||
named: None,
|
||||
_subs: subs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the box is on screen: wanted, and an agent to talk to.
|
||||
pub(super) fn composer_shown(&self) -> bool {
|
||||
self.composer.as_ref().is_some_and(|c| c.open) && self.agent().is_some()
|
||||
}
|
||||
|
||||
/// Open the box and put the caret in it; from inside it, close it; with
|
||||
/// it open but the terminal focused, go back into it.
|
||||
///
|
||||
/// A pane with no agent in the foreground has nobody to compose for, so
|
||||
/// the chord does nothing there.
|
||||
pub fn toggle_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.agent().is_none() {
|
||||
return;
|
||||
}
|
||||
self.ensure_composer(window, cx);
|
||||
let Some(c) = self.composer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let focus = c.input.read(cx).focus_handle(cx);
|
||||
match (c.open, focus.is_focused(window)) {
|
||||
(true, true) => {
|
||||
c.open = false;
|
||||
window.focus(&self.focus_handle, cx);
|
||||
}
|
||||
_ => {
|
||||
c.open = true;
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}
|
||||
self.remember_composer(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Esc in the box: hand the keyboard back to the terminal and leave the
|
||||
/// box where it is. Esc is also what interrupts an agent, and a box that
|
||||
/// closed on it would put the next Esc — the one meant for the agent — a
|
||||
/// keystroke further away than the user expects.
|
||||
pub(super) fn leave_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.focus(&self.focus_handle, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Text arriving for the box by way of the terminal's paste path — files
|
||||
/// dropped on it, a pasted screenshot saved to disk, an upload to a remote
|
||||
/// pane — so every route the terminal already knows lands in the box
|
||||
/// instead when the box has the keyboard.
|
||||
///
|
||||
/// Hands `text` back when the box is not the one taking it.
|
||||
pub(super) fn composer_takes_paste(
|
||||
&mut self,
|
||||
text: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
if !self.composer_focused || !self.composer_shown() {
|
||||
return Some(text);
|
||||
}
|
||||
self.composer.as_mut()?.pending.push(text);
|
||||
cx.notify();
|
||||
None
|
||||
}
|
||||
|
||||
/// Per-frame upkeep: restore a box the pane had open before this view
|
||||
/// existed, take in pending text, and give the keyboard back to the
|
||||
/// terminal when the agent the box was for has gone.
|
||||
pub(super) fn sync_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.composer.is_none()
|
||||
&& self.agent().is_some()
|
||||
&& cx
|
||||
.try_global::<ComposerMemory>()
|
||||
.is_some_and(|m| m.0.contains_key(&self.composer_key()))
|
||||
{
|
||||
self.ensure_composer(window, cx);
|
||||
}
|
||||
let shown = self.composer_shown();
|
||||
let agent = self.agent();
|
||||
let Some(c) = self.composer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(agent) = agent
|
||||
&& c.named != Some(agent)
|
||||
{
|
||||
c.named = Some(agent);
|
||||
let placeholder = t_fmt(
|
||||
L10nKey::ComposerPlaceholder,
|
||||
&[("agent", agent.display_name())],
|
||||
);
|
||||
c.input.update(cx, |state, cx| {
|
||||
state.set_placeholder(placeholder, window, cx)
|
||||
});
|
||||
}
|
||||
if !c.pending.is_empty() {
|
||||
let text = std::mem::take(&mut c.pending).concat();
|
||||
c.input
|
||||
.update(cx, |state, cx| state.insert(text, window, cx));
|
||||
}
|
||||
if !shown && self.composer_focused {
|
||||
self.composer_focused = false;
|
||||
window.focus(&self.focus_handle, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_composer_event(
|
||||
&mut self,
|
||||
_input: &Entity<InputState>,
|
||||
event: &InputEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
InputEvent::Change => self.remember_composer(cx),
|
||||
InputEvent::PressEnter { shift: false, .. } => self.submit_composer(window, cx),
|
||||
InputEvent::PressEnter { .. } => {}
|
||||
InputEvent::Focus => {
|
||||
self.composer_focused = true;
|
||||
cx.notify();
|
||||
}
|
||||
InputEvent::Blur => {
|
||||
self.composer_focused = false;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent is asking a question only its TUI can put — a permission
|
||||
/// prompt, a choice. Whatever the box sent would be taken as the answer,
|
||||
/// so Enter holds the message until the question is gone.
|
||||
fn agent_is_asking(&self) -> bool {
|
||||
self.agent_session()
|
||||
.is_some_and(|s| s.status == AgentStatus::Waiting)
|
||||
}
|
||||
|
||||
pub(super) fn submit_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(agent) = self.agent() else {
|
||||
return;
|
||||
};
|
||||
if self.agent_is_asking() {
|
||||
return;
|
||||
}
|
||||
let Some(c) = self.composer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let text = c.input.read(cx).value().to_string();
|
||||
let bracketed = self
|
||||
.terminal
|
||||
.term
|
||||
.lock()
|
||||
.mode()
|
||||
.contains(TermMode::BRACKETED_PASTE);
|
||||
let steps = submit_plan(agent, &text, bracketed);
|
||||
c.input
|
||||
.update(cx, |state, cx| state.set_value("", window, cx));
|
||||
self.remember_composer(cx);
|
||||
if let Some(c) = self.composer.as_mut() {
|
||||
c.queue.extend(steps);
|
||||
}
|
||||
self.pump_composer(agent, cx);
|
||||
}
|
||||
|
||||
/// Write queued steps one at a time, each after its own delay.
|
||||
///
|
||||
/// Every write after a wait checks that `agent` is still what the pane is
|
||||
/// running: an agent that quit inside the wait has handed the pty back to
|
||||
/// the shell, and the rest of the message — its Enter above all — would
|
||||
/// run there as a command.
|
||||
fn pump_composer(&mut self, agent: CLIAgent, cx: &mut Context<Self>) {
|
||||
let Some(c) = self.composer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if c.pumping {
|
||||
return;
|
||||
}
|
||||
c.pumping = true;
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
// Popping the last step and standing the pump down are one
|
||||
// update, so a submission can never find it still "pumping"
|
||||
// after it has stopped looking at the queue.
|
||||
let next = this
|
||||
.update(cx, |view, _| {
|
||||
let c = view.composer.as_mut()?;
|
||||
let step = c.queue.pop_front();
|
||||
c.pumping = step.is_some();
|
||||
step
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(step) = next else { return };
|
||||
if !step.delay.is_zero() {
|
||||
cx.background_executor().timer(step.delay).await;
|
||||
}
|
||||
let sent = this.update(cx, |view, cx| {
|
||||
if view.agent() != Some(agent) {
|
||||
if let Some(c) = view.composer.as_mut() {
|
||||
c.queue.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
view.send_to_pty(&step.bytes, cx);
|
||||
});
|
||||
if sent.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(super) fn render_composer(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
if !self.composer_shown() {
|
||||
return None;
|
||||
}
|
||||
let c = self.composer.as_ref()?;
|
||||
let agent = self.agent()?;
|
||||
let theme = cx.theme();
|
||||
let focused = c.input.read(cx).focus_handle(cx).is_focused(window);
|
||||
let asking = self.agent_is_asking();
|
||||
let border = match (asking, focused) {
|
||||
(true, _) => theme.warning,
|
||||
(false, true) => theme.ring,
|
||||
(false, false) => theme.border,
|
||||
};
|
||||
let hint = match asking {
|
||||
true => t_fmt(
|
||||
L10nKey::ComposerAgentAsking,
|
||||
&[("agent", agent.display_name())],
|
||||
),
|
||||
false => t(L10nKey::ComposerKeys).to_string(),
|
||||
};
|
||||
let hint_color = match asking {
|
||||
true => theme.warning,
|
||||
false => theme.muted_foreground,
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.id("composer")
|
||||
.flex_none()
|
||||
.w_full()
|
||||
.pt(px(6.))
|
||||
// The terminal surface this sits in focuses the grid on any
|
||||
// click and opens its own context menu on a right one. Neither
|
||||
// is right for a click on the box.
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _: &MouseDownEvent, window, cx| {
|
||||
window.prevent_default();
|
||||
// A click on the frame around the text, not only on
|
||||
// the text, is a click on the box.
|
||||
if let Some(c) = this.composer.as_ref()
|
||||
&& !this.composer_focused
|
||||
{
|
||||
let focus = c.input.read(cx).focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(|this, _: &MouseDownEvent, _w, cx| {
|
||||
this.context_menu_allowed = false;
|
||||
cx.stop_propagation();
|
||||
}),
|
||||
)
|
||||
.on_scroll_wheel(|_, _, cx| cx.stop_propagation())
|
||||
.capture_action(
|
||||
cx.listener(|this, _: &gpui_component::input::Paste, _w, cx| {
|
||||
// Text pastes are the box's own. A copied file or a
|
||||
// screenshot has no text to paste, and the terminal
|
||||
// already knows how to turn those into a path.
|
||||
let Some(item) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
if item.text().is_some() && !super::view::clipboard_has_paths(&item) {
|
||||
return;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
this.paste_from_clipboard(cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.bg(theme.popover)
|
||||
.child(Input::new(&c.input).appearance(false))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.text_xs()
|
||||
.text_color(hint_color)
|
||||
.child(hint),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bytes(steps: &[Step]) -> Vec<&[u8]> {
|
||||
steps.iter().map(|s| s.bytes.as_slice()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_line_is_typed_then_entered_separately() {
|
||||
let steps = submit_plan(CLIAgent::Claude, "fix the tests", true);
|
||||
assert_eq!(bytes(&steps), [&b"fix the tests"[..], b"\r"]);
|
||||
assert_eq!(steps[0].delay, Duration::ZERO);
|
||||
assert_eq!(steps[1].delay, SETTLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_lines_go_as_one_paste() {
|
||||
let steps = submit_plan(CLIAgent::Claude, "one\r\ntwo\n", true);
|
||||
assert_eq!(bytes(&steps), [&b"\x1b[200~one\ntwo\x1b[201~"[..], b"\r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_bracketed_paste_line_breaks_stay_newlines_not_enters() {
|
||||
let steps = submit_plan(CLIAgent::Gemini, "one\ntwo", false);
|
||||
assert_eq!(bytes(&steps), [&b"one\ntwo"[..], b"\r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_is_always_pasted() {
|
||||
let steps = submit_plan(CLIAgent::Codex, "hi", true);
|
||||
assert_eq!(bytes(&steps), [&b"\x1b[200~hi\x1b[201~"[..], b"\r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_bang_reaches_claude_as_a_key_of_its_own() {
|
||||
let steps = submit_plan(CLIAgent::Claude, "!git status", true);
|
||||
assert_eq!(bytes(&steps), [&b"!"[..], b"git status", b"\r"]);
|
||||
assert_eq!(steps[1].delay, SETTLE);
|
||||
// Anyone else gets the line as written.
|
||||
let steps = submit_plan(CLIAgent::Gemini, "!git status", true);
|
||||
assert_eq!(bytes(&steps), [&b"!git status"[..], b"\r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_sends_enter_alone() {
|
||||
let steps = submit_plan(CLIAgent::Claude, " \n", true);
|
||||
assert_eq!(
|
||||
steps,
|
||||
[Step {
|
||||
delay: Duration::ZERO,
|
||||
bytes: b"\r".to_vec()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_cannot_close_the_paste_early() {
|
||||
let steps = submit_plan(CLIAgent::Claude, "a\n\x1b[201~b", true);
|
||||
assert_eq!(bytes(&steps)[0], b"\x1b[200~a\n[201~b\x1b[201~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copilot_waits_longer_after_a_paste() {
|
||||
let steps = submit_plan(CLIAgent::Copilot, "a\nb", true);
|
||||
assert_eq!(steps[1].delay, SETTLE_AFTER_PASTE_SLOW);
|
||||
let steps = submit_plan(CLIAgent::Copilot, "ab", true);
|
||||
assert_eq!(steps[1].delay, SETTLE);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod cmd_editor;
|
||||
mod color_scheme;
|
||||
mod command_cursor;
|
||||
mod completion;
|
||||
mod composer;
|
||||
pub mod element;
|
||||
pub mod fps;
|
||||
mod fuzzy;
|
||||
|
||||
+188
-4
@@ -124,7 +124,8 @@ actions!(
|
||||
FindPrevious,
|
||||
ClearScrollback,
|
||||
InsertNewline,
|
||||
InsertNewlineFallback
|
||||
InsertNewlineFallback,
|
||||
ToggleComposer
|
||||
]
|
||||
);
|
||||
|
||||
@@ -387,7 +388,7 @@ pub struct TerminalView {
|
||||
/// mouse-down, latched so the menu builder — which gpui-component runs on a
|
||||
/// deferred callback, one turn after the click — can still see the
|
||||
/// modifiers the user actually held.
|
||||
context_menu_allowed: bool,
|
||||
pub(super) context_menu_allowed: bool,
|
||||
/// The file link the most recent right mouse-down landed on, latched for
|
||||
/// the same reason [`context_menu_allowed`](Self::context_menu_allowed)
|
||||
/// is: by the time the menu is built the pointer is only a memory.
|
||||
@@ -405,6 +406,10 @@ pub struct TerminalView {
|
||||
pub search: Option<SearchState>,
|
||||
pub cursor_visible: bool,
|
||||
pub(super) search_focused: bool,
|
||||
/// The message box docked under an agent, once it has been asked for —
|
||||
/// see [`super::composer`].
|
||||
pub(super) composer: Option<super::composer::Composer>,
|
||||
pub(super) composer_focused: bool,
|
||||
pub(super) search_case_sensitive: bool,
|
||||
pub(super) search_regex: bool,
|
||||
pub(super) search_regex_error: bool,
|
||||
@@ -956,7 +961,7 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
|
||||
/// — shows no penalty this harness can resolve. Halving the bound would halve
|
||||
/// the worst case; the number is a judgement about how much latency a long
|
||||
/// typed line may pay, not something the curve picks out.
|
||||
fn types_cleanly(line: &str) -> bool {
|
||||
pub(super) fn types_cleanly(line: &str) -> bool {
|
||||
line.len() <= 512 && !line.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
@@ -1028,6 +1033,11 @@ fn pasted_paths_text(paths: &[String], shell: Option<&str>) -> String {
|
||||
format!("{} ", words.join(" "))
|
||||
}
|
||||
|
||||
/// Whether the clipboard carries files rather than (only) text.
|
||||
pub(super) fn clipboard_has_paths(item: &ClipboardItem) -> bool {
|
||||
!clipboard_paths(item).is_empty()
|
||||
}
|
||||
|
||||
fn write_clipboard_image(img: &gpui::Image) -> Option<std::path::PathBuf> {
|
||||
use gpui::ImageFormat;
|
||||
let dir = std::env::temp_dir().join("tty7-clipboard");
|
||||
@@ -1883,6 +1893,8 @@ impl TerminalView {
|
||||
cursor_visible: true,
|
||||
dim: 1.,
|
||||
search_focused: false,
|
||||
composer: None,
|
||||
composer_focused: false,
|
||||
search_case_sensitive: false,
|
||||
search_regex: false,
|
||||
search_regex_error: false,
|
||||
@@ -2601,6 +2613,14 @@ impl TerminalView {
|
||||
let ks = reshaped.as_ref().unwrap_or(&ev.keystroke);
|
||||
let m = &ks.modifiers;
|
||||
|
||||
if self.composer_focused {
|
||||
if ks.key == "escape" {
|
||||
self.leave_composer(window, cx);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.search.is_some() && self.search_focused {
|
||||
if ks.key == "escape" {
|
||||
self.close_search(window, cx);
|
||||
@@ -3316,7 +3336,7 @@ impl TerminalView {
|
||||
self.scroll_frac = 0.;
|
||||
}
|
||||
|
||||
fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
|
||||
pub(super) fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
|
||||
if self.terminal.exited || !self.accepts_input(cx) {
|
||||
return;
|
||||
}
|
||||
@@ -3351,6 +3371,9 @@ impl TerminalView {
|
||||
if !self.accepts_input(cx) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = self.composer_takes_paste(text, cx) else {
|
||||
return;
|
||||
};
|
||||
// Same reason as `commit_text`: what is pasted lands on the prompt, so
|
||||
// the prompt is what has to be on screen. Neither branch below moved
|
||||
// the viewport, and a paste is a bigger change than a keystroke to
|
||||
@@ -7517,6 +7540,7 @@ impl Render for TerminalView {
|
||||
self.sync_frame_facts();
|
||||
self.sync_typeahead_owner();
|
||||
self.sync_scrollbar();
|
||||
self.sync_composer(window, cx);
|
||||
if self.shell_owns_prompt() {
|
||||
if let Some((_net, bytes)) = self.hold.release() {
|
||||
self.terminal.write(bytes);
|
||||
@@ -7546,6 +7570,7 @@ impl Render for TerminalView {
|
||||
.input_active()
|
||||
.then(|| self.render_reverse_search_menu(cx))
|
||||
.flatten();
|
||||
let composer = self.render_composer(window, cx);
|
||||
let integration_notice = self.render_integration_notice(cx);
|
||||
let remote_completion_notice = self.render_remote_completion_notice(cx);
|
||||
|
||||
@@ -7564,6 +7589,10 @@ impl Render for TerminalView {
|
||||
.key_context(self.key_context())
|
||||
.size_full()
|
||||
.relative()
|
||||
// A column so the composer docks under the grid and takes its rows
|
||||
// from it: the grid's laid-out height is what sizes the pty.
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.px(px(GRID_PAD_X))
|
||||
.py(px(GRID_PAD_Y))
|
||||
@@ -7660,6 +7689,9 @@ impl Render for TerminalView {
|
||||
.on_action(cx.listener(|this, _: &InsertNewlineFallback, _w, cx| {
|
||||
this.insert_newline_fallback_action(cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ToggleComposer, window, cx| {
|
||||
this.toggle_composer(window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &SendTab, _w, cx| {
|
||||
this.tab_pressed(true, cx);
|
||||
}))
|
||||
@@ -7667,6 +7699,7 @@ impl Render for TerminalView {
|
||||
this.tab_pressed(false, cx);
|
||||
}))
|
||||
.child(TerminalElement::new(entity))
|
||||
.children(composer)
|
||||
.child(self.render_scrollbar())
|
||||
.children(search_bar)
|
||||
.children(input_bar)
|
||||
@@ -15614,6 +15647,157 @@ mod gpui_tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The composer end to end on a live pane: only there while an agent is,
|
||||
/// a message leaves as one paste and then its own Enter, nothing leaves
|
||||
/// while the agent is asking something in its TUI, and the box gets out
|
||||
/// of the way when the agent quits.
|
||||
#[gpui::test]
|
||||
fn the_composer_hands_a_message_to_the_agent_and_steps_aside_after(cx: &mut TestAppContext) {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
|
||||
|
||||
let (window, view, mut daemon) = rooted_harness(cx);
|
||||
let settle = |cx: &mut TestAppContext| {
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_millis(400));
|
||||
cx.run_until_parked();
|
||||
};
|
||||
let wait_for = |cx: &mut TestAppContext, want: &dyn Fn(&TerminalView) -> bool| {
|
||||
for _ in 0..200 {
|
||||
if cx.update(|cx| want(view.read(cx))) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the pane never got there");
|
||||
};
|
||||
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
view.update(cx, |v, cx| v.toggle_composer(window, cx));
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
cx.update(|cx| view.read(cx).composer.is_none()),
|
||||
"a shell has nobody to compose for"
|
||||
);
|
||||
|
||||
DaemonMsg::Agent(Some(CLIAgent::Claude))
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
// What every agent's TUI does as it starts.
|
||||
DaemonMsg::Output(b"\x1b[?2004h".to_vec())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait_for(cx, &|v| {
|
||||
v.agent() == Some(CLIAgent::Claude)
|
||||
&& v.terminal
|
||||
.term
|
||||
.lock()
|
||||
.mode()
|
||||
.contains(TermMode::BRACKETED_PASTE)
|
||||
});
|
||||
let rows_before = cx.update(|cx| view.read(cx).terminal.size().rows);
|
||||
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
view.update(cx, |v, cx| {
|
||||
v.toggle_composer(window, cx);
|
||||
assert!(v.composer_shown());
|
||||
let input = v.composer.as_ref().unwrap().input.clone();
|
||||
assert!(
|
||||
input.read(cx).focus_handle(cx).is_focused(window),
|
||||
"opening the box puts the caret in it"
|
||||
);
|
||||
input.update(cx, |s, cx| s.set_value("one\ntwo", window, cx));
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
settle(cx);
|
||||
assert!(
|
||||
cx.update(|cx| view.read(cx).terminal.size().rows) < rows_before,
|
||||
"the box takes its rows from the grid rather than covering it"
|
||||
);
|
||||
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
view.update(cx, |v, cx| v.submit_composer(window, cx));
|
||||
})
|
||||
.unwrap();
|
||||
settle(cx);
|
||||
assert_eq!(next_input(&mut daemon), b"\x1b[200~one\ntwo\x1b[201~");
|
||||
assert_eq!(
|
||||
next_input(&mut daemon),
|
||||
b"\r",
|
||||
"Enter goes as its own write"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.update(|cx| view
|
||||
.read(cx)
|
||||
.composer
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.input
|
||||
.read(cx)
|
||||
.value()),
|
||||
"",
|
||||
"a sent message leaves the box"
|
||||
);
|
||||
|
||||
DaemonMsg::AgentStatus(Some(AgentSessionState {
|
||||
status: AgentStatus::Waiting,
|
||||
..Default::default()
|
||||
}))
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait_for(cx, &|v| {
|
||||
v.agent_session()
|
||||
.is_some_and(|s| s.status == AgentStatus::Waiting)
|
||||
});
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
view.update(cx, |v, cx| {
|
||||
let input = v.composer.as_ref().unwrap().input.clone();
|
||||
input.update(cx, |s, cx| s.set_value("yes", window, cx));
|
||||
v.submit_composer(window, cx);
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
None,
|
||||
"a question in the TUI is not answered from the box"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.update(|cx| view
|
||||
.read(cx)
|
||||
.composer
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.input
|
||||
.read(cx)
|
||||
.value()),
|
||||
"yes",
|
||||
"the held message stays in the box"
|
||||
);
|
||||
|
||||
DaemonMsg::Agent(None).encode(&mut daemon).unwrap();
|
||||
wait_for(cx, &|v| v.agent().is_none());
|
||||
window.update(cx, |_, window, cx| window.refresh()).unwrap();
|
||||
settle(cx);
|
||||
window
|
||||
.update(cx, |_, window, cx| {
|
||||
view.update(cx, |v, cx| {
|
||||
assert!(!v.composer_shown(), "no agent, no box");
|
||||
assert!(
|
||||
v.focus_handle.is_focused(window),
|
||||
"the keyboard goes back to the terminal"
|
||||
);
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The selection that seeds the search query is the thing being searched
|
||||
/// for — opening the bar must not erase it, and closing the bar must not
|
||||
/// either (#584). Only *changing* the query retires it.
|
||||
|
||||
@@ -6772,6 +6772,11 @@ impl Tty7App {
|
||||
leaf.update(cx, |view, cx| view.open_search(window, cx));
|
||||
}
|
||||
}
|
||||
ToggleComposer => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.toggle_composer(window, cx));
|
||||
}
|
||||
}
|
||||
FindNext => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.find_step(true, cx));
|
||||
|
||||
@@ -1854,6 +1854,14 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::CmdGitToggleGraph => "Git: Toggle Commit History",
|
||||
L10nKey::CmdClearScrollback => "Clear Scrollback",
|
||||
L10nKey::CmdFindInTerminal => "Find in Terminal…",
|
||||
L10nKey::CmdToggleComposer => "Toggle Message Composer",
|
||||
L10nKey::ComposerPlaceholder => "Message {agent}…",
|
||||
L10nKey::ComposerKeys => {
|
||||
"Enter to send · Shift+Enter for a new line · Esc to go back to the terminal"
|
||||
}
|
||||
L10nKey::ComposerAgentAsking => {
|
||||
"{agent} is asking something. Answer it in the terminal, then send."
|
||||
}
|
||||
L10nKey::CmdFindNext => "Find Next",
|
||||
L10nKey::CmdFindPrevious => "Find Previous",
|
||||
L10nKey::CmdCopy => "Copy",
|
||||
|
||||
@@ -1907,6 +1907,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdGitToggleGraph => "Git: コミット履歴の表示切替",
|
||||
L10nKey::CmdClearScrollback => "スクロールバックをクリア",
|
||||
L10nKey::CmdFindInTerminal => "ターミナル内を検索…",
|
||||
L10nKey::CmdToggleComposer => "メッセージ入力欄の切り替え",
|
||||
L10nKey::ComposerPlaceholder => "{agent} にメッセージ…",
|
||||
L10nKey::ComposerKeys => "Enter で送信 · Shift+Enter で改行 · Esc でターミナルに戻る",
|
||||
L10nKey::ComposerAgentAsking => {
|
||||
"{agent} が質問しています。ターミナルで答えてから送信してください。"
|
||||
}
|
||||
L10nKey::CmdFindNext => "次を検索",
|
||||
L10nKey::CmdFindPrevious => "前を検索",
|
||||
L10nKey::CmdCopy => "コピー",
|
||||
|
||||
@@ -1567,6 +1567,10 @@ l10n_keys! {
|
||||
CmdGitToggleGraph,
|
||||
CmdClearScrollback,
|
||||
CmdFindInTerminal,
|
||||
CmdToggleComposer,
|
||||
ComposerPlaceholder,
|
||||
ComposerKeys,
|
||||
ComposerAgentAsking,
|
||||
CmdFindNext,
|
||||
CmdFindPrevious,
|
||||
CmdCopy,
|
||||
|
||||
@@ -1719,6 +1719,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::CmdGitFetch => "Git:获取",
|
||||
L10nKey::CmdGitToggleGraph => "Git:显示 / 隐藏提交历史",
|
||||
L10nKey::CmdFindInTerminal => "在终端中查找…",
|
||||
L10nKey::CmdToggleComposer => "切换消息输入框",
|
||||
L10nKey::ComposerPlaceholder => "给 {agent} 发消息…",
|
||||
L10nKey::ComposerKeys => "Enter 发送 · Shift+Enter 换行 · Esc 回到终端",
|
||||
L10nKey::ComposerAgentAsking => "{agent} 正在等你回答,请先在终端里回答再发送。",
|
||||
L10nKey::CmdFindNext => "查找下一个",
|
||||
L10nKey::CmdFindPrevious => "查找上一个",
|
||||
L10nKey::CmdCopy => "复制",
|
||||
|
||||
+14
-2
@@ -4,7 +4,7 @@ use crate::core::actions::*;
|
||||
use crate::core::config::{Config, KeybindingOverride};
|
||||
use crate::terminal::view::{
|
||||
AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious,
|
||||
InsertNewline, InsertNewlineFallback, PasteText,
|
||||
InsertNewline, InsertNewlineFallback, PasteText, ToggleComposer,
|
||||
};
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
use crate::ui::palette;
|
||||
@@ -594,6 +594,13 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
per_platform("secondary-k", "secondary-shift-k"),
|
||||
),
|
||||
("InsertNewline", INSERT_NEWLINE_DEFAULT),
|
||||
// ⌘I for "input". Nothing in the table holds it, and no shell or agent
|
||||
// reads Cmd at all. Off macOS Ctrl+I is Tab, so the chord takes Shift
|
||||
// like every other terminal command there.
|
||||
(
|
||||
"ToggleComposer",
|
||||
per_platform("secondary-i", "secondary-shift-i"),
|
||||
),
|
||||
("CopyText", per_platform("", "ctrl-shift-c")),
|
||||
("PasteText", paste_text_default()),
|
||||
("AlternatePaste", alternate_paste_default()),
|
||||
@@ -993,6 +1000,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||||
t(L10nKey::CmdFindInTerminal).to_string(),
|
||||
),
|
||||
"FindNext" => (CommandGroup::Terminal, t(L10nKey::CmdFindNext).to_string()),
|
||||
"ToggleComposer" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::CmdToggleComposer).to_string(),
|
||||
),
|
||||
"FindPrevious" => (
|
||||
CommandGroup::Terminal,
|
||||
t(L10nKey::CmdFindPrevious).to_string(),
|
||||
@@ -1665,7 +1676,7 @@ fn keystroke_is_valid(s: &str) -> bool {
|
||||
fn action_context(action: &str) -> Option<&'static str> {
|
||||
match action {
|
||||
"FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline"
|
||||
| "CopyText" | "PasteText" => Some("Terminal"),
|
||||
| "CopyText" | "PasteText" | "ToggleComposer" => Some("Terminal"),
|
||||
// `alt_screen` is declared by the pane whenever a full-screen program
|
||||
// owns the grid, so this binding is simply absent there and Ctrl+V
|
||||
// carries on to the PTY as SYN (#677).
|
||||
@@ -1779,6 +1790,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"ToggleDiffViewMode" => KeyBinding::new(keystroke, ToggleDiffViewMode, None),
|
||||
"FindInTerminal" => KeyBinding::new(keystroke, FindInTerminal, action_context(action)),
|
||||
"FindNext" => KeyBinding::new(keystroke, FindNext, action_context(action)),
|
||||
"ToggleComposer" => KeyBinding::new(keystroke, ToggleComposer, action_context(action)),
|
||||
"FindPrevious" => KeyBinding::new(keystroke, FindPrevious, action_context(action)),
|
||||
"ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, action_context(action)),
|
||||
"InsertNewline" => KeyBinding::new(keystroke, InsertNewline, action_context(action)),
|
||||
|
||||
@@ -63,6 +63,7 @@ pub enum CommandKind {
|
||||
ShowRightPanel(RightPanelTab),
|
||||
ClearTerminal,
|
||||
FindInTerminal,
|
||||
ToggleComposer,
|
||||
FindNext,
|
||||
FindPrevious,
|
||||
CopyText,
|
||||
@@ -243,6 +244,7 @@ impl CommandKind {
|
||||
ShowRightPanel(RightPanelTab::GitHub) => "right-panel-github",
|
||||
ClearTerminal => "clear-scrollback",
|
||||
FindInTerminal => "find",
|
||||
ToggleComposer => "composer",
|
||||
FindNext => "find-next",
|
||||
FindPrevious => "find-previous",
|
||||
CopyText => "copy",
|
||||
@@ -391,6 +393,7 @@ impl CommandKind {
|
||||
},
|
||||
ClearTerminal => "ClearScrollback",
|
||||
FindInTerminal => "FindInTerminal",
|
||||
ToggleComposer => "ToggleComposer",
|
||||
FindNext => "FindNext",
|
||||
FindPrevious => "FindPrevious",
|
||||
ReopenClosedTab => "ReopenClosedTab",
|
||||
@@ -758,6 +761,7 @@ impl Item {
|
||||
let terminal = [
|
||||
Item::localized(L10nKey::CmdClearScrollback, ClearTerminal),
|
||||
Item::localized(L10nKey::CmdFindInTerminal, FindInTerminal),
|
||||
Item::localized(L10nKey::CmdToggleComposer, ToggleComposer),
|
||||
Item::localized(L10nKey::CmdFindNext, FindNext),
|
||||
Item::localized(L10nKey::CmdFindPrevious, FindPrevious),
|
||||
Item::localized(L10nKey::CmdCopy, CopyText),
|
||||
@@ -928,6 +932,7 @@ mod tests {
|
||||
CommandKind::PasteText,
|
||||
CommandKind::SelectAllText,
|
||||
CommandKind::FindInTerminal,
|
||||
CommandKind::ToggleComposer,
|
||||
CommandKind::FindNext,
|
||||
CommandKind::FindPrevious,
|
||||
CommandKind::OpenSettings,
|
||||
|
||||
Reference in New Issue
Block a user