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:
l0ng-ai
2026-10-01 15:27:59 +08:00
parent c361bd71e8
commit 24a37e2c5c
10 changed files with 818 additions and 6 deletions
+583
View File
@@ -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);
}
}
+1
View File
@@ -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
View File
@@ -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.
+5
View File
@@ -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));
+8
View File
@@ -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",
+6
View File
@@ -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 => "コピー",
+4
View File
@@ -1567,6 +1567,10 @@ l10n_keys! {
CmdGitToggleGraph,
CmdClearScrollback,
CmdFindInTerminal,
CmdToggleComposer,
ComposerPlaceholder,
ComposerKeys,
ComposerAgentAsking,
CmdFindNext,
CmdFindPrevious,
CmdCopy,
+4
View File
@@ -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
View File
@@ -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)),
+5
View File
@@ -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,