diff --git a/src/terminal/composer.rs b/src/terminal/composer.rs new file mode 100644 index 00000000..bf9b1cec --- /dev/null +++ b/src/terminal/composer.rs @@ -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, +} + +/// 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 { + 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, + /// 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, + 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, + /// 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, + _subs: Vec, +} + +impl TerminalView { + fn composer_key(&self) -> (HostId, u64) { + (self.host_id(), self.pane_id) + } + + fn remember_composer(&self, cx: &mut Context) { + 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::(); + 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) { + if self.composer.is_some() { + return; + } + let remembered = cx + .try_global::() + .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) { + 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) { + 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, + ) -> Option { + 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) { + if self.composer.is_none() + && self.agent().is_some() + && cx + .try_global::() + .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, + event: &InputEvent, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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) { + 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, + ) -> Option { + 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); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ea85cf21..d2155b80 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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; diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 744edd76..bd3a952c 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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, 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, + 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 { /// — 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 { 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) { + pub(super) fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context) { 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. diff --git a/src/ui/app.rs b/src/ui/app.rs index f674c5fc..e7cc2088 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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)); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 67048332..a89f1bc7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 21beab5d..4ee1cef1 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 => "コピー", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 787b3fd2..f2bd201f 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1567,6 +1567,10 @@ l10n_keys! { CmdGitToggleGraph, CmdClearScrollback, CmdFindInTerminal, + CmdToggleComposer, + ComposerPlaceholder, + ComposerKeys, + ComposerAgentAsking, CmdFindNext, CmdFindPrevious, CmdCopy, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index bbe9d48a..9ef02e7b 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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 => "复制", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 493f06c7..ef89dbf6 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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 { "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)), diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 66845efa..b88fd1e5 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -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,