Files
tty7/src/terminal/view.rs
T
ayamir 83d9a1c547 feat(graphics): render kitty graphics with shared-memory transport (#272)
Adds kitty graphics protocol support: a daemon-side APC tokenizer lifts image transmissions out of the PTY stream before the replay ring, forwards them out-of-band as compact binary frames interleaved in stream order, and the client decodes off-thread with newest-wins coalescing per image id. Local panes take the file/shm fast path; remote panes keep pixels compressed in-tunnel. Cell size is now reported to children in device pixels so pixel-aware senders render at native resolution.

Closes #213.
2026-07-31 13:47:59 +08:00

7916 lines
263 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use alacritty_terminal::event::Event as AlacEvent;
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::{Column, Direction, Line, Point, Side};
use alacritty_terminal::selection::{Selection, SelectionType};
use alacritty_terminal::term::TermMode;
use gpui::{
App, ClipboardEntry, ClipboardItem, Context, ExternalPaths, FocusHandle, Focusable, Font,
KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta, ScrollWheelEvent,
Window, actions, div, prelude::*, px,
};
use gpui_component::kbd::Kbd;
use gpui_component::menu::{ContextMenuExt, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex};
use super::TermSize;
use super::cmd_editor::CmdEditor;
use super::completion::{self, CandidateKind, CompletionSession};
use super::element::TerminalElement;
use super::highlight::{self, TokenKind};
use super::hold::{GapHold, Verdict};
use super::remote::RemoteTerminal;
use super::reverse_search::{self, ReverseSearch};
use super::search::{LinkTarget, SearchState};
use super::typeahead::{RawInput, Typeahead};
use crate::core::actions::{
CloseActiveTab, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight,
ForkAgentSessionUp, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
};
use crate::core::config::{BellMode, Config, NotifyMode};
use crate::daemon::protocol::{RemoteContext, ShellSpec};
const GRID_PAD_X: f32 = 8.;
const GRID_PAD_Y: f32 = 4.;
actions!(
terminal,
[
CopyText,
CutText,
PasteText,
SelectAll,
UndoEdit,
RedoEdit,
FindInTerminal,
FindNext,
FindPrevious,
ClearScrollback,
InsertNewline
]
);
pub struct ChildExited;
impl gpui::EventEmitter<ChildExited> for TerminalView {}
pub struct AuthPromptReady;
impl gpui::EventEmitter<AuthPromptReady> for TerminalView {}
pub struct AgentSessionChanged;
impl gpui::EventEmitter<AgentSessionChanged> for TerminalView {}
pub struct NativeSshParts {
terminal: RemoteTerminal,
pane_id: u64,
persist: Box<crate::daemon::protocol::NativeSshSpec>,
}
pub struct ShellParts {
terminal: RemoteTerminal,
pub(crate) pane_id: u64,
shell_spec: Option<ShellSpec>,
pub(crate) workspace: Option<crate::terminal::PaneWorkspace>,
pub(crate) restored: bool,
pub(crate) owner: Option<crate::core::session::WorkspaceId>,
}
#[derive(Clone, Copy)]
struct DragScroll {
overshoot: f32,
col: usize,
side: Side,
}
fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool {
match pane_runs_remotely {
false => host_is_local,
true => !host_is_local,
}
}
pub struct TerminalView {
pub terminal: RemoteTerminal,
host_id: crate::ui::host_ops::HostId,
workspace: Option<crate::terminal::PaneWorkspace>,
pub pane_id: u64,
shell_spec: Option<ShellSpec>,
owner_workspace: Option<crate::core::session::WorkspaceId>,
restored: bool,
ssh_spec: Option<Box<crate::daemon::protocol::NativeSshSpec>>,
pub focus_handle: FocusHandle,
pub font: Font,
pub font_bold: Option<Font>,
pub font_italic: Option<Font>,
font_features: Option<gpui::FontFeatures>,
pub font_size: Pixels,
pub line_height_mul: f32,
pub cell_width: Pixels,
line_height: Pixels,
selecting: bool,
drag_scroll: Option<DragScroll>,
drag_scroll_epoch: u64,
pub title: String,
pub marked_text: String,
last_mouse_cell: Option<(usize, usize)>,
last_hover_cell: Option<(usize, usize)>,
link_modifier_down: bool,
scroll_debt: f32,
pub(super) scroll_frac: f32,
pub search: Option<SearchState>,
pub cursor_visible: bool,
pub focused: bool,
pub(super) search_focused: bool,
pub(super) search_case_sensitive: bool,
pub(super) search_regex: bool,
pub(super) search_regex_error: bool,
pub(super) search_last_query: String,
pub bell_flash: bool,
pub report_mouse: bool,
last_at_prompt: bool,
running_since: Option<std::time::Instant>,
running_title: String,
running_agent: Option<crate::core::cli_agent::CLIAgent>,
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
last_agent_session: (Option<String>, Option<Vec<String>>),
agent_turn_started: Option<std::time::Instant>,
agent_was_rich: bool,
agent_result_unread: bool,
keep_unread_on_focus: bool,
git_status_cwd: Option<std::path::PathBuf>,
last_agent_activity: u64,
cmd: CmdEditor,
typeahead: Typeahead,
hold: GapHold,
history: Vec<String>,
history_counts: std::collections::HashMap<String, u32>,
history_cwds: std::collections::HashMap<String, std::collections::HashSet<String>>,
history_meta: std::collections::HashMap<String, super::history::EntryMeta>,
history_ranked: Vec<String>,
history_frecency: Vec<f64>,
history_scope: super::history::Scope,
ranked_cwd: Option<std::path::PathBuf>,
history_nav: Option<usize>,
history_stash: String,
last_word_nav: Option<LastWordWalk>,
pending_history: Option<PendingHistory>,
completion: Option<CompletionSession>,
remote_completion_inflight: bool,
completion_generation: u64,
editor_handoff: Option<u64>,
reverse_search: Option<ReverseSearch>,
integration_notice: Option<String>,
integration_notice_shown: bool,
created_at: std::time::Instant,
editor_selecting: bool,
editor_select_gesture: bool,
editor_drag_word: Option<(usize, usize)>,
editor_goal_col: Option<usize>,
pub(super) hovered_link: Option<HoveredLink>,
_focus_subs: Vec<gpui::Subscription>,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct HoveredLink {
pub start: Point,
pub end: Point,
}
enum LoopbackOpen {
Forwarded(String),
ForwardFailed(String),
NotLoopback,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum LoopbackPlan {
Direct,
NoForwardNeeded,
ForwardOnPane(u64),
ForwardOnWorkspace(Box<crate::terminal::PaneWorkspace>),
}
pub(super) fn loopback_plan(
enabled: bool,
workspace: Option<&crate::terminal::PaneWorkspace>,
remote_kind: Option<crate::daemon::protocol::RemoteKind>,
pane_id: u64,
) -> LoopbackPlan {
if !enabled {
return LoopbackPlan::Direct;
}
if let Some(ws) = workspace {
if ws.shares_localhost() {
return LoopbackPlan::NoForwardNeeded;
}
if ws.spec.is_none() {
log::warn!("remote workspace has no connection spec; not forwarding localhost links");
return LoopbackPlan::Direct;
}
return LoopbackPlan::ForwardOnWorkspace(Box::new(ws.clone()));
}
match remote_kind {
Some(crate::daemon::protocol::RemoteKind::NativeSsh) => {
LoopbackPlan::ForwardOnPane(pane_id)
}
_ => LoopbackPlan::Direct,
}
}
struct PendingHistory {
line: String,
cwd: Option<std::path::PathBuf>,
ts: u64,
seq: u64,
}
struct LastWordWalk {
entry: usize,
at: usize,
word: String,
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
enum CmdKey {
Consumed,
Bubble,
FallThrough,
}
const HOLD_WINDOW: std::time::Duration = std::time::Duration::from_millis(150);
const INTEGRATION_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500);
const MAX_HISTORY_BYTES: u64 = 4 << 20;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum GitRefresh {
Edge,
Opportunistic,
}
fn known_pty_shim(fg: &str) -> Option<&'static str> {
["kiro-cli-term", "figterm", "qterm", "cwterm"]
.into_iter()
.find(|shim| fg.contains(shim))
}
fn integration_notice_message(wrapper: Option<&str>) -> String {
match wrapper {
Some(w) => format!(
"tty7 shell integration is blocked in this pane — \u{201c}{w}\u{201d} is intercepting \
shell reports, so inline completion and the Ctrl+R menu are unavailable. \
The shell's own history search still works."
),
None => "tty7 shell integration hasn't engaged in this pane, so inline completion and \
the Ctrl+R menu are unavailable. A PTY wrapper (figterm-style) or an \
unsupported shell setup can cause this."
.to_string(),
}
}
fn notify_command_finished(label: &str, elapsed: std::time::Duration) {
let secs = elapsed.as_secs();
let label = label.trim();
let body = if label.is_empty() {
format!("Command finished after {secs}s")
} else {
format!("{label} — finished after {secs}s")
};
super::remote::notify_desktop(Some("tty7"), &body);
}
fn notify_agent_finished(agent: crate::core::cli_agent::CLIAgent, elapsed: std::time::Duration) {
let secs = elapsed.as_secs();
let body = format!("Finished after {secs}s");
super::remote::notify_desktop(Some(agent.display_name()), &body);
}
fn ring_system_bell() -> bool {
#[cfg(target_os = "macos")]
{
objc2_app_kit::NSBeep();
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let text = text.replace("\r\n", "\n");
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend(text.bytes().filter(|&b| b != 0x1b));
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
}
}
fn submit_bytes(line: &str, bracketed: bool) -> Vec<u8> {
let clean: String = line
.replace("\r\n", "\n")
.chars()
.filter(|&c| c != '\x1b')
.map(|c| if c == '\r' { '\n' } else { c })
.collect();
let mut bytes = paste_bytes(&clean, bracketed && !clean.is_empty());
bytes.push(b'\r');
bytes
}
fn trim_trailing_spaces(text: &str) -> String {
text.split('\n')
.map(|line| line.trim_end_matches([' ', '\t']))
.collect::<Vec<_>>()
.join("\n")
}
fn shell_escape_path(path: &str) -> String {
if path.is_empty() {
return "''".to_string();
}
if path.contains(['\n', '\r']) {
return format!("'{}'", path.replace('\'', "'\\''"));
}
let mut out = String::with_capacity(path.len() + 8);
for ch in path.chars() {
if matches!(
ch,
' ' | '\t'
| '"'
| '\''
| '\\'
| '$'
| '`'
| '#'
| '='
| '!'
| '~'
| '['
| ']'
| '{'
| '}'
| '('
| ')'
| '<'
| '>'
| '|'
| ';'
| '*'
| '?'
| '&'
) {
out.push('\\');
}
out.push(ch);
}
out
}
fn clipboard_paste_text(item: &ClipboardItem) -> Option<String> {
let escaped: Vec<String> = item
.entries()
.iter()
.filter_map(|e| match e {
ClipboardEntry::ExternalPaths(paths) => Some(paths.paths()),
_ => None,
})
.flatten()
.map(|p| shell_escape_path(&p.to_string_lossy()))
.collect();
if !escaped.is_empty() {
return Some(escaped.join(" "));
}
item.text()
}
#[cfg(not(target_os = "macos"))]
fn write_clipboard_image(img: &gpui::Image) -> Option<std::path::PathBuf> {
use gpui::ImageFormat;
let dir = std::env::temp_dir().join("tty7-clipboard");
std::fs::create_dir_all(&dir).ok()?;
let (ext, transcoded) = match img.format {
ImageFormat::Png => ("png", None),
ImageFormat::Jpeg => ("jpg", None),
ImageFormat::Gif => ("gif", None),
ImageFormat::Webp => ("webp", None),
other => ("png", Some(transcode_to_png(&img.bytes, other)?)),
};
let data: &[u8] = transcoded.as_deref().unwrap_or(&img.bytes);
let path = dir.join(format!("paste-{:016x}.{ext}", img.id));
std::fs::write(&path, data).ok()?;
Some(path)
}
#[cfg(not(target_os = "macos"))]
fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option<Vec<u8>> {
use gpui::ImageFormat as G;
let src = match format {
G::Png => image::ImageFormat::Png,
G::Jpeg => image::ImageFormat::Jpeg,
G::Webp => image::ImageFormat::WebP,
G::Gif => image::ImageFormat::Gif,
G::Bmp => image::ImageFormat::Bmp,
G::Tiff => image::ImageFormat::Tiff,
G::Ico => image::ImageFormat::Ico,
G::Pnm => image::ImageFormat::Pnm,
G::Svg => return None,
};
let decoded = image::load_from_memory_with_format(bytes, src).ok()?;
let mut out = Vec::new();
decoded
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.ok()?;
Some(out)
}
fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> {
let mut chain = configured.to_vec();
let mut pin = |name: &str| {
if family != name && !chain.iter().any(|f| f == name) {
chain.push(name.to_string());
}
};
for name in crate::core::config::platform_last_resort_fallbacks() {
pin(name);
}
pin("Hack");
chain
}
impl TerminalView {
pub fn spawn_shell_terminal_in(
workspace: Option<crate::terminal::PaneWorkspace>,
working_directory: Option<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
owner: Option<crate::core::session::WorkspaceId>,
) -> anyhow::Result<ShellParts> {
let route = crate::terminal::PaneRoute::for_workspace(workspace.as_ref());
let attached = match restore_pane {
Some(id) => match RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id) {
Ok(terminal) => Some((terminal, id, None)),
Err(e) => {
log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh");
None
}
},
None => None,
};
let restored = attached.is_some();
let (terminal, pane_id, shell_spec) = match attached {
Some(parts) => parts,
None => {
let (terminal, id) = RemoteTerminal::spawn_on(
&route,
TermSize::new(80, 24),
8,
17,
working_directory,
shell.clone(),
owner.map(|id| id.to_string()),
)?;
(terminal, id, shell)
}
};
Ok(ShellParts {
terminal,
pane_id,
shell_spec,
workspace,
restored,
owner,
})
}
pub fn from_shell_parts(
parts: ShellParts,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
view.shell_spec = parts.shell_spec;
view.owner_workspace = parts.owner;
view.restored = parts.restored;
view.set_workspace(parts.workspace);
view
}
pub(crate) fn restored(&self) -> bool {
self.restored
}
pub fn owner_workspace(&self) -> Option<crate::core::session::WorkspaceId> {
self.owner_workspace
}
pub fn spawn_native_ssh_terminal(
spec: Box<crate::daemon::protocol::NativeSshSpec>,
working_directory: Option<std::path::PathBuf>,
) -> anyhow::Result<NativeSshParts> {
let persist = Box::new(spec.without_secrets());
let (terminal, pane_id) = RemoteTerminal::spawn_native_ssh(
TermSize::new(80, 24),
8,
17,
working_directory,
spec,
)?;
Ok(NativeSshParts {
terminal,
pane_id,
persist,
})
}
pub fn from_native_ssh_parts(
parts: NativeSshParts,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
view.ssh_spec = Some(parts.persist);
view
}
fn with_terminal(
terminal: RemoteTerminal,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let config = cx.global::<Config>();
let font_family = config.font_family.clone();
let fallbacks = fallback_chain(&font_family, &config.font_fallbacks);
let font_size = px(config.font_size);
let line_height_mul = config.line_height;
let font_features = config
.font_features
.as_ref()
.map(crate::core::config::gpui_font_features);
let report_mouse = config.mouse_reporting;
let mut font = gpui::font(font_family);
font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone()));
if let Some(features) = &font_features {
font.features = features.clone();
}
let alt_font = |family: &Option<String>| {
family.as_ref().map(|f| {
let mut af = gpui::font(f.clone());
af.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone()));
if let Some(features) = &font_features {
af.features = features.clone();
}
af
})
};
let font_bold = alt_font(&config.font_family_bold);
let font_italic = alt_font(&config.font_family_italic);
let focus_handle = cx.focus_handle();
let events = terminal.events.clone();
cx.spawn(async move |this, cx| {
let mut batch = Vec::new();
while let Ok(ev) = events.recv().await {
batch.push(ev);
while let Ok(ev) = events.try_recv() {
batch.push(ev);
}
let res = this.update(cx, |view, cx| {
let mut woke = false;
for ev in batch.drain(..) {
if matches!(ev, AlacEvent::Wakeup) && std::mem::replace(&mut woke, true) {
continue;
}
view.handle_event(ev, cx);
}
woke
});
let woke = match res {
Ok(woke) => woke,
Err(_) => break,
};
if woke {
let _ = this.update_in(cx, |_, window, _| window.refresh());
}
}
})
.detach();
let focus_subs = vec![
cx.on_focus_in(&focus_handle, window, |view, _window, cx| {
view.focused = true;
view.cursor_visible = true;
if view.keep_unread_on_focus {
view.keep_unread_on_focus = false;
} else {
view.agent_result_unread = false;
}
view.report_focus_change(true);
cx.notify();
}),
cx.on_blur(&focus_handle, window, |view, _window, cx| {
view.focused = false;
view.report_focus_change(false);
cx.notify();
}),
];
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(530))
.await;
if this
.update(cx, |view, cx| {
if view.focused {
if cx.global::<Config>().cursor_blink {
view.cursor_visible = !view.cursor_visible;
cx.notify();
} else if !view.cursor_visible {
view.cursor_visible = true;
cx.notify();
}
}
})
.is_err()
{
break;
}
}
})
.detach();
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(300))
.await;
if this
.update_in(cx, |view, window, cx| view.poll_foreground(window, cx))
.is_err()
{
break;
}
}
})
.detach();
window.focus(&focus_handle, cx);
let history = super::history::load(&super::history::Scope::Local);
let history_ranked = super::history::rank_by_frecency(
&history.entries,
&history.counts,
&history.cwds,
None,
);
let history_frecency =
super::history::frecency_scores(&history.entries, &history.counts, &history.cwds, None);
Self {
terminal,
host_id: crate::ui::host_ops::HostId::LOCAL,
workspace: None,
pane_id,
shell_spec: None,
owner_workspace: None,
restored: false,
ssh_spec: None,
focus_handle,
font,
font_bold,
font_italic,
font_features,
font_size,
line_height_mul,
cell_width: px(8.),
line_height: px(17.),
selecting: false,
drag_scroll: None,
drag_scroll_epoch: 0,
title: "tty7".to_string(),
marked_text: String::new(),
last_mouse_cell: None,
report_mouse,
last_hover_cell: None,
link_modifier_down: false,
scroll_debt: 0.,
scroll_frac: 0.,
search: None,
cursor_visible: true,
focused: true,
search_focused: false,
search_case_sensitive: false,
search_regex: false,
search_regex_error: false,
search_last_query: String::new(),
bell_flash: false,
last_at_prompt: false,
running_since: None,
running_title: String::new(),
running_agent: None,
last_agent_status: None,
last_agent_session: (None, None),
agent_turn_started: None,
agent_was_rich: false,
agent_result_unread: false,
keep_unread_on_focus: false,
git_status_cwd: None,
last_agent_activity: 0,
cmd: CmdEditor::new(),
typeahead: Typeahead::new(),
hold: GapHold::new(),
history: history.entries,
history_counts: history.counts,
history_cwds: history.cwds,
history_meta: history.meta,
history_ranked,
history_frecency,
history_scope: super::history::Scope::Local,
ranked_cwd: None,
history_nav: None,
history_stash: String::new(),
last_word_nav: None,
pending_history: None,
completion: None,
completion_generation: 0,
editor_handoff: None,
remote_completion_inflight: false,
reverse_search: None,
integration_notice: None,
integration_notice_shown: false,
created_at: std::time::Instant::now(),
editor_selecting: false,
editor_select_gesture: false,
editor_drag_word: None,
editor_goal_col: None,
hovered_link: None,
_focus_subs: focus_subs,
}
}
pub fn set_grid_size(
&mut self,
cols: usize,
rows: usize,
cell_width: Pixels,
line_height: Pixels,
scale: f32,
) {
if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) {
self.last_hover_cell = None;
self.hovered_link = None;
}
self.cell_width = cell_width;
self.line_height = line_height;
// Report the cell size to the child in *device* pixels (logical × display
// scale), so `ws_xpixel`/`ws_ypixel` describe the real framebuffer. A
// pixel-aware program like terminal-browser renders its frame at that
// native resolution; painted back into logical-pixel bounds, gpui blits
// it ~1:1 on the framebuffer instead of upscaling a half-resolution
// bitmap (which looked soft and magnified on Retina). This is what
// kitty/ghostty report. `self.cell_width` stays logical — glyph layout
// and mouse mapping work in logical pixels.
let scale = if scale.is_finite() && scale > 0. {
scale
} else {
1.
};
self.terminal.resize(
TermSize::new(cols, rows),
(cell_width.as_f32() * scale).round().max(1.) as u16,
(line_height.as_f32() * scale).round().max(1.) as u16,
);
}
pub fn cwd(&self) -> Option<std::path::PathBuf> {
self.terminal.foreground_cwd()
}
pub fn remote_context(&self) -> Option<RemoteContext> {
self.terminal.remote_context()
}
pub fn local_cwd(&self) -> Option<std::path::PathBuf> {
self.paths_are_local().then(|| self.cwd())?
}
fn paths_are_local(&self) -> bool {
self.remote_context().is_none() && self.host_id.is_local()
}
pub fn spawnable_cwd(&self) -> Option<std::path::PathBuf> {
self.remote_context().is_none().then(|| self.cwd())?
}
pub fn host(&self, cx: &gpui::App) -> Option<crate::ui::host_ops::SharedHost> {
crate::ui::host_registry::HostRegistry::lookup(cx, self.host_id)
}
pub fn host_id(&self) -> crate::ui::host_ops::HostId {
self.host_id
}
pub fn workspace(&self) -> Option<&crate::terminal::PaneWorkspace> {
self.workspace.as_ref()
}
pub fn set_workspace(&mut self, workspace: Option<crate::terminal::PaneWorkspace>) {
self.host_id = workspace
.as_ref()
.map_or(crate::ui::host_ops::HostId::LOCAL, |w| w.target.host_id());
self.workspace = workspace;
}
pub fn pane_route(&self) -> crate::terminal::PaneRoute {
crate::terminal::PaneRoute::for_workspace(self.workspace.as_ref())
}
fn accepts_input(&self, cx: &gpui::App) -> bool {
let Some(ws) = self.workspace().map(|w| w.workspace) else {
return true;
};
crate::ui::remote_workspace::workspace_accepts_input(cx, ws)
}
pub fn relink_plan(&self) -> (u64, TermSize, u16, u16) {
(
self.pane_id,
self.terminal.size(),
self.cell_width.as_f32().round() as u16,
self.line_height.as_f32().round() as u16,
)
}
pub fn adopt_relink(
&mut self,
stream: crate::daemon::transport::Stream,
route: &crate::terminal::PaneRoute,
size: TermSize,
cell_w: u16,
cell_h: u16,
cx: &mut Context<Self>,
) -> anyhow::Result<()> {
self.terminal
.adopt_relink(stream, route, size, cell_w, cell_h)?;
self.title = "tty7".to_string();
cx.notify();
Ok(())
}
pub fn detach_link(&mut self, cx: &mut Context<Self>) {
self.terminal.detach_link();
cx.notify();
}
pub fn host_cwd(&self) -> Option<std::path::PathBuf> {
self.cwd_is_on_host().then(|| self.cwd())?
}
fn cwd_is_on_host(&self) -> bool {
cwd_is_on_host(!self.paths_are_local(), self.host_id.is_local())
}
pub fn agent(&self) -> Option<crate::core::cli_agent::CLIAgent> {
self.terminal.foreground_agent()
}
pub fn agent_session(&self) -> Option<crate::core::cli_agent::AgentSessionState> {
self.terminal.agent_session()
}
pub fn agent_result_unread(&self) -> bool {
self.agent_result_unread
}
pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) {
self.agent_result_unread = true;
self.keep_unread_on_focus = refocus_incoming;
}
pub fn git_status(&self, cx: &App) -> Option<crate::terminal::git_status::GitStatus> {
let cwd = self.git_status_cwd.as_ref()?;
cx.try_global::<crate::terminal::git_status::GitStatusCache>()?
.status_for(self.host_id, cwd)
}
pub fn git_status_cwd(&self) -> Option<&std::path::Path> {
self.git_status_cwd.as_deref()
}
pub fn refresh_git_status_now(&mut self, cx: &mut Context<Self>) {
let cwd = self.git_status_cwd.clone();
if cwd.is_some() {
self.refresh_git_status(cwd, GitRefresh::Opportunistic, cx);
}
}
pub fn selection_text(&self) -> Option<String> {
self.terminal
.term
.lock()
.selection_to_string()
.filter(|t| !t.trim().is_empty())
}
pub fn send_agent_prompt(&self, prompt: &str) {
self.terminal
.write(crate::core::agent_prompt::submit_bytes(prompt));
}
pub fn run_command_line(&self, cmd: &str) {
self.terminal.write(format!("{cmd}\r").into_bytes());
}
pub fn shell_spec(&self) -> Option<ShellSpec> {
self.shell_spec.clone()
}
pub fn ssh_spec(&self) -> Option<Box<crate::daemon::protocol::NativeSshSpec>> {
self.ssh_spec.clone()
}
pub fn ssh_phase(&self) -> Option<crate::daemon::protocol::SshPhase> {
self.terminal.ssh_phase()
}
pub fn ssh_disconnected(&self) -> bool {
self.ssh_spec.is_some() && self.terminal.exited
}
fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context<Self>) {
self.terminal.poll_exited();
if self.terminal.has_pending_auth() {
cx.emit(AuthPromptReady);
}
match ev {
AlacEvent::Wakeup => cx.notify(),
AlacEvent::Title(title) => {
self.title = title;
cx.notify();
}
AlacEvent::ResetTitle => {
self.title = "tty7".to_string();
cx.notify();
}
AlacEvent::PtyWrite(text) => self.terminal.write(text.into_bytes()),
AlacEvent::ChildExit(_) | AlacEvent::Exit => {
self.terminal.exited = true;
self.title = if self.workspace().is_some() && !self.terminal.child_exited() {
"tty7 — disconnected".to_string()
} else {
"tty7 — process exited".to_string()
};
if self.terminal.child_exited() {
cx.emit(ChildExited);
}
cx.notify();
}
AlacEvent::ClipboardStore(_, text) => {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
AlacEvent::ClipboardLoad(_, fmt) => {
if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) {
self.terminal.write(fmt(&text).into_bytes());
}
}
AlacEvent::ColorRequest(idx, fmt) => {
let theme = cx.theme();
let rgb = match idx {
256 => super::palette::hsla_to_rgb(theme.foreground),
257 => super::palette::hsla_to_rgb(theme.background),
258 => super::palette::hsla_to_rgb(theme.caret),
i => self.terminal.palette[i.min(255)],
};
self.terminal.write(fmt(rgb).into_bytes());
}
AlacEvent::Bell => match cx.global::<Config>().bell {
BellMode::None => {}
BellMode::Visual => self.flash_bell(cx),
BellMode::Audible => {
if !ring_system_bell() {
self.flash_bell(cx);
}
}
},
AlacEvent::TextAreaSizeRequest(fmt) => {
let size = self.terminal.size();
let reply = fmt(alacritty_terminal::event::WindowSize {
num_lines: size.rows as u16,
num_cols: size.cols as u16,
cell_width: self.cell_width.as_f32().round() as u16,
cell_height: self.line_height.as_f32().round() as u16,
});
self.terminal.write(reply.into_bytes());
}
_ => {}
}
}
fn report_focus_change(&self, focused: bool) {
let mode = *self.terminal.term.lock().mode();
if let Some(bytes) = focus_report_bytes(mode, focused) {
self.terminal.write(bytes);
}
}
fn on_key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let link_dropped_on_a_remote_pane =
self.workspace().is_some() && !self.terminal.child_exited();
if self.terminal.exited && !link_dropped_on_a_remote_pane {
return;
}
if self.integration_notice.take().is_some() {
cx.notify();
}
let reshaped = if cfg!(target_os = "macos") {
super::input::reshape_option_keystroke(
&ev.keystroke,
cx.global::<Config>().macos_option_as_alt,
)
} else {
None
};
let ks = reshaped.as_ref().unwrap_or(&ev.keystroke);
let m = &ks.modifiers;
if self.search.is_some() && self.search_focused {
if ks.key == "escape" {
self.close_search(window, cx);
cx.stop_propagation();
}
return;
}
if m.platform && !m.control && !m.alt {
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
cx.stop_propagation();
return;
}
CmdKey::Bubble => return,
CmdKey::FallThrough => {}
}
}
if cfg!(not(target_os = "macos"))
&& m.control
&& !m.platform
&& !m.alt
&& matches!(ks.key.as_str(), "c" | "v" | "x")
{
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
cx.stop_propagation();
return;
}
CmdKey::Bubble | CmdKey::FallThrough => {}
}
}
if cfg!(not(target_os = "macos"))
&& m.control
&& !m.platform
&& !m.alt
&& matches!(
ks.key.as_str(),
"1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
)
{
return;
}
if !self.accepts_input(cx) {
return;
}
#[cfg(target_os = "macos")]
if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) {
return;
}
if self.input_active() {
self.handle_editor_key(ks, cx);
cx.stop_propagation();
return;
}
if m.control
&& !m.platform
&& !m.alt
&& ks.key == "r"
&& cx.global::<Config>().history_search
{
self.note_integration_gap(cx);
}
let kitty = self.kitty_flags();
if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) {
let plain = !m.control && !m.alt && !m.platform;
let shell_owns_prompt = self.shell_owns_prompt();
let held = plain
&& ks.key == "backspace"
&& !shell_owns_prompt
&& self.gap_holdable()
&& match self.hold.hold_backspace(&bytes) {
Verdict::Held(arm) => {
if let Some(epoch) = arm {
self.arm_hold_timer(epoch, cx);
}
true
}
Verdict::Passthrough => false,
};
if !held {
self.release_hold();
self.terminal.write(bytes);
if !shell_owns_prompt {
self.typeahead.observe(
RawInput::Key {
key: ks.key.as_str(),
plain,
},
self.on_alt_screen(),
);
}
}
self.cursor_visible = true;
self.jump_to_prompt();
cx.notify();
cx.stop_propagation();
}
}
fn handle_cmd_shortcut(
&mut self,
ks: &gpui::Keystroke,
_window: &mut Window,
cx: &mut Context<Self>,
) -> CmdKey {
let m = &ks.modifiers;
match ks.key.as_str() {
"c" => {
if self.copy_contextual(m.control, cx) {
CmdKey::Consumed
} else {
CmdKey::FallThrough
}
}
"x" => {
if self.cut_contextual(cx) {
CmdKey::Consumed
} else {
CmdKey::FallThrough
}
}
"v" => {
self.paste_from_clipboard(cx);
CmdKey::Consumed
}
"a" => {
self.select_all_contextual(cx);
CmdKey::Consumed
}
"z" => {
self.undo_edit(m.shift, cx);
CmdKey::Consumed
}
"left" => {
if self.input_active() {
self.editor_move_edge(false, m.shift);
cx.notify();
}
CmdKey::Consumed
}
"right" => {
if self.input_active() {
self.editor_move_edge(true, m.shift);
cx.notify();
}
CmdKey::Consumed
}
"backspace" => {
if self.input_active() {
if !self.cmd.delete_selection() {
self.cmd.delete_to_start();
}
self.close_completion();
self.cursor_visible = true;
cx.notify();
}
CmdKey::Consumed
}
"delete" => {
if self.input_active() {
if !self.cmd.delete_selection() {
self.cmd.delete_to_end();
}
self.close_completion();
self.cursor_visible = true;
cx.notify();
}
CmdKey::Consumed
}
_ => CmdKey::Bubble,
}
}
fn handle_editor_key(&mut self, ks: &gpui::Keystroke, cx: &mut Context<Self>) {
let m = &ks.modifiers;
let key = ks.key.as_str();
self.cursor_visible = true;
self.jump_to_prompt();
let aliased;
let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") {
aliased = gpui::Keystroke {
modifiers: gpui::Modifiers::default(),
key: if key == "p" { "up" } else { "down" }.to_string(),
key_char: None,
};
&aliased
} else {
ks
};
let m = &ks.modifiers;
let key = ks.key.as_str();
if key != "up" && key != "down" {
self.editor_goal_col = None;
}
if !(m.alt && key == ".") {
self.last_word_nav = None;
}
if self.reverse_search.is_some() {
self.handle_reverse_search_key(ks, cx);
return;
}
if m.control && !m.platform && !m.alt && matches!(key, "j" | "m") {
self.accept_line(cx);
return;
}
if self.completion.is_some() && !m.control && !m.alt {
match (m.platform, key) {
(false, "up") => {
self.completion_select(false, cx);
return;
}
(false, "down") => {
self.completion_select(true, cx);
return;
}
(false, "enter") => {
self.accept_line(cx);
return;
}
(true, "enter") => {
self.completion_accept(cx);
self.submit_command(cx);
return;
}
(false, "escape") => {
self.close_completion();
cx.notify();
return;
}
(false, "backspace") if self.cmd.selection().is_none() && !self.cmd.is_empty() => {
self.cmd.backspace();
self.completion_refilter();
self.cursor_visible = true;
cx.notify();
return;
}
_ => {}
}
}
self.close_completion();
if m.control && !m.platform && !m.alt {
if cfg!(not(target_os = "macos")) {
match key {
"left" => {
self.editor_move_h(false, m.shift, true);
cx.notify();
return;
}
"right" => {
self.editor_move_h(true, m.shift, true);
cx.notify();
return;
}
"backspace" => {
if !self.cmd.delete_selection() {
self.cmd.delete_word_left();
}
self.history_nav = None;
cx.notify();
return;
}
"delete" => {
if !self.cmd.delete_selection() {
self.cmd.delete_word_right();
}
cx.notify();
return;
}
_ => {}
}
}
if cfg!(not(target_os = "macos")) && key == "a" {
self.cmd.select_all();
self.close_completion();
self.cursor_visible = true;
cx.notify();
return;
}
if key == "r" && !cx.global::<Config>().history_search {
self.handoff_line_to_shell(&[0x12], cx);
return;
}
if self.apply_readline_ctrl(key) {
cx.notify();
} else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) {
self.handoff_line_to_shell(&bytes, cx);
} else {
cx.notify();
}
return;
}
if m.alt && !m.platform && !m.control {
match key {
"." => {
self.insert_last_word(cx);
return;
}
"b" => {
self.editor_move_h(false, m.shift, true);
cx.notify();
return;
}
"f" => {
self.editor_move_h(true, m.shift, true);
cx.notify();
return;
}
"d" => {
if !self.cmd.delete_selection() {
self.cmd.delete_word_right();
}
self.history_nav = None;
cx.notify();
return;
}
_ => {}
}
}
match key {
"enter" => {
self.submit_command(cx);
return;
}
"backspace" => {
if self.cmd.is_empty() {
self.terminal.write(vec![0x7f]);
self.typeahead.observe(
RawInput::Key {
key: "backspace",
plain: true,
},
false,
);
return;
}
if m.alt && self.cmd.selection().is_none() {
self.cmd.delete_word_left();
} else {
self.cmd.backspace();
}
self.history_nav = None;
}
"delete" => {
if m.alt {
self.cmd.delete_word_right();
} else {
self.cmd.delete();
}
}
"left" => self.editor_move_h(false, m.shift, m.alt),
"right" => {
if !m.shift && self.cmd.selection().is_none() {
if let Some(full) = self.ghost_suggestion() {
self.cmd.set(&full);
cx.notify();
return;
}
}
self.editor_move_h(true, m.shift, m.alt);
}
"home" => self.editor_move_edge(false, m.shift),
"end" => self.editor_move_edge(true, m.shift),
"up" => {
if self.editor_move_v(false, m.shift) {
cx.notify();
} else {
self.history_prev(cx);
}
return;
}
"down" => {
if self.editor_move_v(true, m.shift) {
cx.notify();
} else {
self.history_next(cx);
}
return;
}
"escape" => {
let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
.unwrap_or_else(|| vec![0x1b]);
self.terminal.write(bytes);
return;
}
_ => {
if !m.control && !m.platform && !m.alt {
if let Some(ch) = ks.key_char.as_deref() {
if !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}') {
self.commit_text(ch, cx);
return;
}
}
}
if m.alt && !m.control && !m.platform && key.chars().count() == 1 {
let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
.unwrap_or_else(|| {
let name = if m.shift {
key.to_uppercase()
} else {
key.to_string()
};
let mut b = vec![0x1b];
b.extend_from_slice(name.as_bytes());
b
});
self.handoff_line_to_shell(&bytes, cx);
return;
}
}
}
cx.notify();
}
fn apply_readline_ctrl(&mut self, key: &str) -> bool {
match key {
"r" => self.start_reverse_search(),
"a" => {
self.cmd.clear_selection();
self.cmd.move_home();
}
"e" => {
self.cmd.clear_selection();
self.cmd.move_end();
}
"b" => {
self.cmd.clear_selection();
self.cmd.move_left();
}
"f" => {
if let Some(full) = self.ghost_suggestion() {
self.cmd.set(&full);
} else {
self.cmd.clear_selection();
self.cmd.move_right();
}
}
"w" => {
if !self.cmd.delete_selection() {
self.cmd.delete_word_left();
}
}
"u" => {
if !self.cmd.delete_selection() {
self.cmd.delete_to_start();
}
}
"k" => {
if !self.cmd.delete_selection() {
self.cmd.delete_to_end();
}
}
"h" => self.cmd.backspace(),
"y" => self.cmd.yank(),
"l" => {
self.terminal.write(vec![0x0c]);
}
"c" => {
self.cmd.clear();
self.history_nav = None;
let _ = self.typeahead.drain();
let _ = self.hold.engage();
self.terminal.write(vec![0x03]);
}
"d" => {
if self.cmd.is_empty() {
self.wipe_pending_typeahead();
self.terminal.write(vec![0x04]);
} else {
self.cmd.delete();
}
}
_ => return false,
}
true
}
fn editor_move_h(&mut self, right: bool, shift: bool, word: bool) {
if shift {
self.cmd.begin_selection();
} else if let Some((s, e)) = self.cmd.selection() {
self.cmd.set_cursor(if right { e } else { s });
self.cmd.clear_selection();
return;
}
match (right, word) {
(false, false) => self.cmd.move_left(),
(false, true) => self.cmd.move_word_left(),
(true, false) => self.cmd.move_right(),
(true, true) => self.cmd.move_word_right(),
}
}
fn editor_move_edge(&mut self, end: bool, shift: bool) {
if shift {
self.cmd.begin_selection();
} else {
self.cmd.clear_selection();
}
if end {
self.cmd.move_end();
} else {
self.cmd.move_home();
}
}
fn editor_move_v(&mut self, down: bool, shift: bool) -> bool {
let Some((_, scol)) = self.cursor_cell() else {
return false;
};
let cols = self.terminal.term.lock().columns().max(1);
let chars: Vec<char> = self.cmd.text().chars().collect();
let len = chars.len();
let (positions, _r, _c) = input_char_positions(&chars, scol, cols);
let end_caret = if len == 0 {
(0usize, scol)
} else {
let (r, c, w) = positions[len - 1];
if chars[len - 1] == '\n' {
(r + 1, 0)
} else {
(r, c + w)
}
};
let (cur_row, cur_col) = if self.cmd.cursor() < len {
let (r, c, _) = positions[self.cmd.cursor()];
(r, c)
} else {
end_caret
};
let mut max_row = positions.iter().map(|&(r, _, _)| r).max().unwrap_or(0);
if chars.last() == Some(&'\n') {
max_row += 1;
}
if (down && cur_row >= max_row) || (!down && cur_row == 0) {
self.editor_goal_col = None;
return false;
}
let target = if down { cur_row + 1 } else { cur_row - 1 };
let goal = *self.editor_goal_col.get_or_insert(cur_col);
let mut best: Option<(usize, usize)> = None;
for (i, &(r, c, _)) in positions.iter().enumerate() {
if r == target {
let dist = c.abs_diff(goal);
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((i, dist));
}
}
}
if end_caret.0 == target {
let dist = end_caret.1.abs_diff(goal);
if best.is_none_or(|(_, bd)| dist < bd) {
best = Some((len, dist));
}
}
let Some((idx, _)) = best else {
return false;
};
if shift {
self.cmd.begin_selection();
} else {
self.cmd.clear_selection();
}
self.cmd.set_cursor(idx);
true
}
fn has_selection(&self) -> bool {
self.terminal.term.lock().selection.is_some()
}
fn any_selection(&self) -> bool {
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
}
pub(super) fn kitty_flags(&self) -> super::input::KittyFlags {
super::input::KittyFlags::from_mode(self.terminal.term.lock().mode())
}
fn tab_bytes(&self, shift: bool) -> Vec<u8> {
super::input::tab_bytes(shift, self.kitty_flags())
}
fn jump_to_prompt(&mut self) {
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
drop(term);
self.scroll_frac = 0.;
}
fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
if self.terminal.exited || !self.accepts_input(cx) {
return;
}
self.terminal.write(bytes.to_vec());
self.cursor_visible = true;
self.jump_to_prompt();
cx.notify();
}
pub fn select_all(&mut self, cx: &mut Context<Self>) {
let mut term = self.terminal.term.lock();
let grid = term.grid();
let start = Point::new(grid.topmost_line(), Column(0));
let end = Point::new(grid.bottommost_line(), grid.last_column());
let mut sel = Selection::new(SelectionType::Simple, start, Side::Left);
sel.update(end, Side::Right);
term.selection = Some(sel);
drop(term);
cx.notify();
}
pub fn select_all_contextual(&mut self, cx: &mut Context<Self>) {
if self.input_active() {
self.cmd.select_all();
cx.notify();
} else {
self.select_all(cx);
}
}
pub fn paste(&mut self, text: String, cx: &mut Context<Self>) {
if !self.accepts_input(cx) {
return;
}
if self.input_active() {
let trimmed = text.strip_suffix('\n').unwrap_or(&text);
self.cmd.insert_str(trimmed);
self.history_nav = None;
self.editor_goal_col = None;
self.close_completion();
self.cursor_visible = true;
cx.notify();
return;
}
let bracketed = self
.terminal
.term
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
self.write_gap_text(&text, paste_bytes(&text, bracketed), cx);
self.terminal.term.lock().selection = None;
cx.notify();
}
fn flash_bell(&mut self, cx: &mut Context<Self>) {
self.bell_flash = true;
cx.notify();
cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(std::time::Duration::from_millis(150))
.await;
let _ = this.update(cx, |view, cx| {
view.bell_flash = false;
cx.notify();
});
})
.detach();
}
pub fn mouse_mode(&self) -> bool {
self.report_mouse
&& self
.terminal
.term
.lock()
.mode()
.intersects(TermMode::MOUSE_MODE)
}
fn write_mouse(&self, base: u8, mods: &Modifiers, col: usize, row: usize, pressed: bool) {
let sgr = self
.terminal
.term
.lock()
.mode()
.contains(TermMode::SGR_MOUSE);
if let Some(msg) = encode_mouse(sgr, base, mods, col, row, pressed) {
self.terminal.write(msg);
}
}
pub fn mouse_press(&mut self, button: MouseButton, col: usize, row: usize, mods: &Modifiers) {
let base = match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
_ => return,
};
self.last_mouse_cell = Some((col, row));
self.write_mouse(base, mods, col, row, true);
}
pub fn mouse_release(&mut self, button: MouseButton, col: usize, row: usize, mods: &Modifiers) {
let base = match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
_ => return,
};
self.write_mouse(base, mods, col, row, false);
}
pub fn mouse_drag(&mut self, button: MouseButton, col: usize, row: usize, mods: &Modifiers) {
if self.last_mouse_cell == Some((col, row)) {
return;
}
let wants = self.report_mouse
&& self
.terminal
.term
.lock()
.mode()
.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION);
if !wants {
return;
}
self.last_mouse_cell = Some((col, row));
let base = match button {
MouseButton::Left => 32,
MouseButton::Middle => 33,
MouseButton::Right => 34,
_ => return,
};
self.write_mouse(base, mods, col, row, true);
}
pub fn mouse_motion(&mut self, col: usize, row: usize, mods: &Modifiers) {
if self.last_mouse_cell == Some((col, row)) {
return;
}
if !self.report_mouse
|| !self
.terminal
.term
.lock()
.mode()
.contains(TermMode::MOUSE_MOTION)
{
return;
}
self.last_mouse_cell = Some((col, row));
self.write_mouse(35, mods, col, row, true);
}
pub fn scroll(&mut self, lines: i32, mods: &Modifiers, cx: &mut Context<Self>) {
if lines == 0 {
return;
}
let mut mode = *self.terminal.term.lock().mode();
if !self.report_mouse {
mode.remove(TermMode::MOUSE_MODE);
}
match wheel_route(mode, mods.shift, lines > 0) {
WheelRoute::Report { base } => {
let (col, row) = self.last_mouse_cell.unwrap_or((0, 0));
for _ in 0..lines.unsigned_abs() {
self.write_mouse(base, mods, col, row, true);
}
}
WheelRoute::Arrows { seq } => {
let mut out = Vec::with_capacity(seq.len() * lines.unsigned_abs() as usize);
for _ in 0..lines.unsigned_abs() {
out.extend_from_slice(seq);
}
self.terminal.write(out);
}
WheelRoute::Scrollback => {
self.scroll_frac = 0.;
self.terminal
.term
.lock()
.scroll_display(Scroll::Delta(lines));
cx.notify();
}
}
}
pub fn copy_selection(&mut self, cx: &mut Context<Self>) {
let text = self.terminal.term.lock().selection_to_string();
if let Some(mut text) = text {
if cx.global::<Config>().clipboard_trim_trailing_spaces {
text = trim_trailing_spaces(&text);
}
if !text.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}
}
pub fn copy_contextual(&mut self, clear_on_copy: bool, cx: &mut Context<Self>) -> bool {
if self.input_active() {
if let Some(text) = self.cmd.selected_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
if clear_on_copy {
self.cmd.clear_selection();
cx.notify();
}
return true;
}
}
if self.has_selection() {
self.copy_selection(cx);
if clear_on_copy {
self.terminal.term.lock().selection = None;
cx.notify();
}
return true;
}
false
}
pub fn find_step(&mut self, forward: bool, cx: &mut Context<Self>) {
let direction = if forward {
Direction::Right
} else {
Direction::Left
};
self.step_match(direction, cx);
}
pub fn undo_edit(&mut self, redo: bool, cx: &mut Context<Self>) {
if !self.input_active() {
return;
}
if redo {
self.cmd.redo();
} else {
self.cmd.undo();
}
self.close_completion();
cx.notify();
}
pub fn cut_contextual(&mut self, cx: &mut Context<Self>) -> bool {
if !self.input_active() {
return false;
}
if let Some(text) = self.cmd.selected_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
self.cmd.delete_selection();
self.close_completion();
self.cursor_visible = true;
cx.notify();
}
true
}
pub fn paste_from_clipboard(&mut self, cx: &mut Context<Self>) {
let Some(item) = cx.read_from_clipboard() else {
return;
};
if let Some(text) = clipboard_paste_text(&item) {
self.paste(text, cx);
return;
}
if self.input_active() {
return;
}
if let Some(img) = item.entries().iter().find_map(|e| match e {
ClipboardEntry::Image(img) => Some(img),
_ => None,
}) {
self.paste_clipboard_image(img, cx);
}
}
fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) {
let text = paths
.paths()
.iter()
.map(|p| shell_escape_path(&p.to_string_lossy()))
.collect::<Vec<_>>()
.join(" ");
if text.is_empty() {
return;
}
self.paste(format!("{text} "), cx);
}
fn paste_clipboard_image(&mut self, img: &gpui::Image, cx: &mut Context<Self>) {
#[cfg(not(target_os = "macos"))]
if let Some(path) = write_clipboard_image(img) {
let text = shell_escape_path(&path.to_string_lossy());
self.paste(format!("{text} "), cx);
return;
}
let _ = img;
self.terminal.write(vec![0x16]);
self.terminal.term.lock().selection = None;
cx.notify();
}
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
self.terminal.term.lock().grid_mut().clear_history();
self.scroll_frac = 0.;
self.terminal.marks().clear();
self.terminal.write(vec![0x0c_u8]);
cx.notify();
}
pub fn set_font_family(&mut self, family: String, cx: &mut Context<Self>) {
let fallbacks = self.font.fallbacks.clone();
let mut font = gpui::font(family);
font.fallbacks = fallbacks;
if let Some(features) = &self.font_features {
font.features = features.clone();
}
self.font = font;
cx.notify();
}
pub fn set_font_family_bold(&mut self, family: Option<String>, cx: &mut Context<Self>) {
self.font_bold = self.alt_font(family);
cx.notify();
}
pub fn set_font_family_italic(&mut self, family: Option<String>, cx: &mut Context<Self>) {
self.font_italic = self.alt_font(family);
cx.notify();
}
pub fn set_font_features(
&mut self,
features: Option<gpui::FontFeatures>,
cx: &mut Context<Self>,
) {
self.font_features = features.clone();
let apply = |font: &mut Font| {
font.features = features.clone().unwrap_or_default();
};
apply(&mut self.font);
if let Some(font) = &mut self.font_bold {
apply(font);
}
if let Some(font) = &mut self.font_italic {
apply(font);
}
cx.notify();
}
fn alt_font(&self, family: Option<String>) -> Option<Font> {
family.map(|f| {
let mut af = gpui::font(f);
af.fallbacks = self.font.fallbacks.clone();
if let Some(features) = &self.font_features {
af.features = features.clone();
}
af
})
}
fn poll_foreground(&mut self, window: &Window, cx: &mut Context<Self>) {
if self.terminal.exited {
return;
}
let at_prompt = self.terminal.at_prompt();
if self
.pending_history
.as_ref()
.is_some_and(|p| at_prompt && self.terminal.prompt_seq() > p.seq)
{
self.flush_pending_history();
cx.notify();
}
if let Some(cwd) = self.cwd()
&& self.ranked_cwd.as_ref() != Some(&cwd)
{
self.rerank_history(Some(&cwd));
}
if self.integration_notice.is_some() && self.terminal.shell_active() {
self.integration_notice = None;
cx.notify();
}
if at_prompt != self.last_at_prompt {
self.last_at_prompt = at_prompt;
cx.notify();
}
let notify_allowed = match cx.global::<Config>().notify_on_command_finish {
NotifyMode::Never => false,
NotifyMode::Unfocused => !window.is_window_active(),
NotifyMode::Always => true,
};
let running = !at_prompt;
if running && self.running_agent.is_none() {
self.running_agent = self.terminal.foreground_agent();
}
let cmd_finished = self.running_since.is_some() && !running;
match (self.running_since, running) {
(None, true) => {
self.running_since = Some(std::time::Instant::now());
self.running_title = self.title.clone();
self.running_agent = self.terminal.foreground_agent();
}
(Some(start), false) => {
let elapsed = start.elapsed();
let title = std::mem::take(&mut self.running_title);
let agent = self.running_agent.take();
self.running_since = None;
if notify_allowed {
match agent {
Some(_) if self.agent_was_rich => {}
Some(agent) => notify_agent_finished(agent, elapsed),
None => {
let threshold = std::time::Duration::from_secs(
cx.global::<Config>().notify_threshold_secs,
);
if elapsed >= threshold {
notify_command_finished(&title, elapsed);
}
}
}
}
}
_ => {}
}
let turn_finished = self.poll_agent_status(notify_allowed, cx);
let session = self.terminal.agent_session();
let tool_activity = match session.as_ref().map(|s| s.activity) {
Some(n) => std::mem::replace(&mut self.last_agent_activity, n) != n,
None => {
self.last_agent_activity = 0;
false
}
};
let cwd_now = self
.cwd_is_on_host()
.then(|| {
session
.as_ref()
.and_then(|s| s.cwd.clone())
.or_else(|| self.cwd())
})
.flatten();
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
self.refresh_git_status(cwd_now, GitRefresh::Edge, cx);
} else if tool_activity {
self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx);
}
self.follow_history_scope(cx);
}
fn desired_history_scope(&self) -> super::history::Scope {
if let Some(ctx) = self.remote_context() {
return super::history::Scope::remote(&ctx.target);
}
if !self.host_id.is_local() {
return super::history::Scope::remote(&format!("host-{:016x}", self.host_id.0));
}
super::history::Scope::Local
}
fn follow_history_scope(&mut self, cx: &mut Context<Self>) {
let scope = self.desired_history_scope();
if scope == self.history_scope {
return;
}
self.flush_pending_history();
self.history_scope = scope.clone();
self.history.clear();
self.history_counts.clear();
self.history_cwds.clear();
self.history_meta.clear();
self.history_ranked.clear();
self.history_frecency.clear();
self.history_nav = None;
self.reverse_search = None;
cx.notify();
let shell_files = self.remote_shell_history_sources(cx);
let loading = scope.clone();
cx.spawn(async move |this, cx| {
let loaded = cx
.background_spawn(async move {
let files = shell_files
.into_iter()
.filter_map(|(host, path)| host.read_file(&path, MAX_HISTORY_BYTES).ok())
.collect();
super::history::load_with_shell_files(&loading, files)
})
.await;
this.update(cx, |view, cx| {
if view.history_scope != scope {
return;
}
view.history = loaded.entries;
view.history_counts = loaded.counts;
view.history_cwds = loaded.cwds;
view.history_meta = loaded.meta;
let cwd = view.ranked_cwd.clone();
view.rerank_history(cwd.as_deref());
cx.notify();
})
.ok();
})
.detach();
}
fn remote_shell_history_sources(
&self,
cx: &mut Context<Self>,
) -> Vec<(crate::ui::host_ops::SharedHost, std::path::PathBuf)> {
if self.history_scope.is_local() || self.host_id.is_local() {
return Vec::new();
}
// The Host reaches the workspace machine's home directory and nothing
// beyond it. A pane that has ssh'ed onward from there (remote_context)
// is scoped to the *inner* target, and seeding that scope from the
// workspace host's ~/.zsh_history would offer commands from the wrong
// box — the exact confusion scoping exists to prevent. Those panes
// start from what tty7 recorded for the inner target, like bare ssh.
if self.remote_context().is_some() {
return Vec::new();
}
let Some(host) = self.host(cx) else {
return Vec::new();
};
if !host.is_connected() {
return Vec::new();
}
let Some(home) = crate::ui::remote_connect::HostLinks::home(cx, self.host_id) else {
return Vec::new();
};
super::history::shell_history_names()
.into_iter()
.map(|name| (std::sync::Arc::clone(&host), host.join(&home, name)))
.collect()
}
fn refresh_git_status(
&mut self,
cwd: Option<std::path::PathBuf>,
trigger: GitRefresh,
cx: &mut Context<Self>,
) {
use crate::terminal::git_status::GitStatusCache;
let changed = self.git_status_cwd != cwd;
self.git_status_cwd = cwd.clone();
let Some(cwd) = cwd else {
if changed {
cx.notify();
}
return;
};
let id = self.host_id;
let Some(host) = self.host(cx) else {
if changed {
cx.notify();
}
return;
};
if !host.is_connected() {
if changed {
cx.notify();
}
return;
}
cx.default_global::<GitStatusCache>();
let claimed = cx.update_global::<GitStatusCache, _>(|cache, _| match trigger {
GitRefresh::Edge => cache.begin_probe(id, &cwd),
GitRefresh::Opportunistic => {
cache.begin_probe_throttled(id, &cwd, OPPORTUNISTIC_GIT_GAP)
}
});
if !claimed {
return;
}
let probe_cwd = cwd.clone();
let pane = cx.weak_entity();
crate::ui::host_ops::HostOps::run_detached(
host,
cx,
move |h| crate::terminal::git_status::probe(h, &probe_cwd),
move |cx, result| {
let rerun = cx.update_global::<GitStatusCache, _>(|cache, _| {
cache.finish_probe(id, &cwd, result)
});
if rerun {
let _ = pane.update(cx, |view, cx| {
if view.git_status_cwd.as_deref() == Some(&cwd) {
view.refresh_git_status(Some(cwd), GitRefresh::Edge, cx);
}
});
}
},
);
}
fn poll_agent_status(&mut self, notify_allowed: bool, cx: &mut Context<Self>) -> bool {
use crate::core::cli_agent::AgentStatus;
let session = self.terminal.agent_session();
if session.as_ref().is_some_and(|s| s.rich) {
self.agent_was_rich = true;
}
if self.terminal.foreground_agent().is_none() && session.is_none() {
self.agent_was_rich = false;
}
let identity = (
session.as_ref().and_then(|s| s.session_id.clone()),
session.as_ref().and_then(|s| s.launch_argv.clone()),
);
if identity != self.last_agent_session {
self.last_agent_session = identity;
cx.emit(AgentSessionChanged);
}
let status = session.as_ref().map(|s| s.status);
if status == self.last_agent_status {
return false;
}
let prev = std::mem::replace(&mut self.last_agent_status, status);
let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done);
match status {
Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => {
self.agent_result_unread = !self.focused;
self.keep_unread_on_focus = false;
}
Some(AgentStatus::Done) => {}
_ => {
self.agent_result_unread = false;
self.keep_unread_on_focus = false;
}
}
let rich = session.as_ref().is_some_and(|s| s.rich);
let agent_name = self
.terminal
.foreground_agent()
.map(|a| a.display_name())
.unwrap_or("Agent");
match status {
Some(AgentStatus::Working) => {
self.agent_turn_started = Some(std::time::Instant::now());
}
Some(AgentStatus::Waiting) if rich && notify_allowed => {
let body = session
.as_ref()
.and_then(|s| s.message.clone())
.unwrap_or_else(|| "Waiting for your input".to_string());
super::remote::notify_desktop(Some(agent_name), &body);
}
Some(AgentStatus::Done)
if rich
&& notify_allowed
&& matches!(
prev,
Some(AgentStatus::Working) | Some(AgentStatus::Waiting)
) =>
{
let body = match self.agent_turn_started.take() {
Some(start) => format!("Finished after {}s", start.elapsed().as_secs()),
None => "Turn finished".to_string(),
};
super::remote::notify_desktop(Some(agent_name), &body);
}
_ => {}
}
cx.notify();
turn_finished
}
fn at_shell_prompt(&self) -> bool {
self.terminal.at_prompt()
}
fn cursor_cell(&self) -> Option<(usize, usize)> {
let term = self.terminal.term.lock();
let content = term.renderable_content();
let row = content.cursor.point.line.0 + content.display_offset as i32;
let col = content.cursor.point.column.0;
(row >= 0).then_some((row as usize, col))
}
pub(super) fn input_scroll_rows(&self) -> usize {
if !self.input_active() || self.reverse_search.is_some() {
return 0;
}
let Some((crow, ccol)) = self.cursor_cell() else {
return 0;
};
let (rows, cols, offset) = {
let term = self.terminal.term.lock();
(
term.screen_lines(),
term.columns(),
term.grid().display_offset(),
)
};
if offset != 0 {
return 0;
}
let chars: Vec<char> = self.cmd.text().chars().collect();
let (visual_rows, caret_vrow) = input_overlay_rows(
&chars,
self.cmd.cursor(),
&self.marked_text,
ccol,
cols.max(1),
);
input_overflow_shift(crow, caret_vrow, visual_rows, rows)
}
fn editor_char_index(&self, col: usize, row: usize, clamp: bool) -> Option<usize> {
if !self.input_active() {
return None;
}
let (srow, scol) = self.cursor_cell()?;
if row < srow {
return clamp.then_some(0);
}
let cols = self.terminal.term.lock().columns().max(1);
let chars: Vec<char> = self.cmd.text().chars().collect();
wrapped_click_index(&chars, scol, cols, col, row - srow, clamp)
}
pub fn editor_click(
&mut self,
col: usize,
row: usize,
clicks: usize,
shift: bool,
cx: &mut Context<Self>,
) -> bool {
let Some(idx) = self.editor_char_index(col, row, false) else {
return false;
};
match clicks {
1 if shift => {
self.cmd.extend_to(idx);
self.editor_selecting = true;
self.editor_drag_word = None;
}
1 => {
self.cmd.set_cursor(idx);
self.cmd.clear_selection();
self.editor_selecting = true;
self.editor_drag_word = None;
}
2 => {
let cfg = cx.global::<Config>();
let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
self.cmd.select_word_at(idx, &seps, smart);
self.editor_selecting = true;
self.editor_drag_word = self.cmd.selection();
}
_ => {
self.cmd.select_all();
self.editor_selecting = false;
self.editor_drag_word = None;
}
}
self.editor_select_gesture = true;
self.editor_goal_col = None;
self.close_completion();
self.cursor_visible = true;
cx.notify();
true
}
pub fn editor_drag(&mut self, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
if !self.editor_selecting {
return false;
}
let Some(idx) = self.editor_char_index(col, row, true) else {
return false;
};
if let Some((s, e)) = self.editor_drag_word {
let cfg = cx.global::<Config>();
let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
self.cmd.extend_word_to(s, e, idx, &seps, smart);
} else {
self.cmd.extend_to(idx);
}
self.cursor_visible = true;
cx.notify();
true
}
pub fn input_active(&self) -> bool {
self.input_inactive_reason().is_none()
}
fn input_inactive_reason(&self) -> Option<&'static str> {
if self.terminal.exited {
return Some("the shell has exited");
}
if self.search_focused {
return Some("the search field holds the keyboard");
}
if self.on_alt_screen() {
return Some("the pane is on the alternate screen");
}
if self.shell_vi_prompt() {
return Some("the shell prompt is in vi mode");
}
if self.editor_handoff == Some(self.terminal.prompt_cycle()) {
return Some("this prompt's line was already handed to the shell");
}
if !self.at_shell_prompt() {
return Some("the shell has not reported a prompt (no OSC 133)");
}
None
}
fn link_inactive_reason(&self, cx: &gpui::App) -> Option<&'static str> {
(!self.accepts_input(cx)).then_some("the remote link is not attached")
}
fn shell_vi_prompt(&self) -> bool {
self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen()
}
fn handoff_active(&self) -> bool {
self.editor_handoff == Some(self.terminal.prompt_cycle())
&& self.terminal.at_prompt()
&& !self.on_alt_screen()
}
fn shell_owns_prompt(&self) -> bool {
self.shell_vi_prompt() || self.handoff_active()
}
fn on_alt_screen(&self) -> bool {
self.terminal
.term
.lock()
.mode()
.contains(TermMode::ALT_SCREEN)
}
fn flush_typeahead(&mut self) {
let Some(seed) = self.typeahead.drain() else {
return;
};
self.terminal.write(vec![0x15]);
if !seed.is_empty() {
self.cmd.prepend_str(&seed);
}
}
fn wipe_pending_typeahead(&mut self) {
if self.typeahead.drain().is_some() {
self.terminal.write(vec![0x15]);
}
}
fn gap_holdable(&self) -> bool {
self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt()
}
fn write_gap_text(&mut self, text: &str, bytes: Vec<u8>, cx: &mut Context<Self>) {
if self.shell_owns_prompt() {
self.release_hold();
self.terminal.write(bytes);
return;
}
if self.gap_holdable() && !text.chars().any(char::is_control) {
match self.hold.hold_text(text, &bytes) {
Verdict::Held(arm) => {
if let Some(epoch) = arm {
self.arm_hold_timer(epoch, cx);
}
return;
}
Verdict::Passthrough => {}
}
} else {
self.release_hold();
}
self.terminal.write(bytes);
let alt = self.on_alt_screen();
self.typeahead.observe(RawInput::Text(text), alt);
}
fn release_hold(&mut self) {
if let Some((net, bytes)) = self.hold.release() {
self.terminal.write(bytes);
let alt = self.on_alt_screen();
self.typeahead.observe(RawInput::Text(&net), alt);
}
}
fn arm_hold_timer(&mut self, epoch: u64, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(HOLD_WINDOW).await;
let _ = this.update(cx, |view, cx| view.dump_hold(epoch, cx));
})
.detach();
}
fn dump_hold(&mut self, epoch: u64, cx: &mut Context<Self>) {
if !self.accepts_input(cx) {
let _ = self.hold.timeout(epoch);
return;
}
if let Some((net, bytes)) = self.hold.timeout(epoch) {
self.terminal.write(bytes);
let alt = self.on_alt_screen();
self.typeahead.observe(RawInput::Text(&net), alt);
cx.notify();
}
}
fn insert_newline_action(&mut self, cx: &mut Context<Self>) {
if !self.input_active() || self.reverse_search.is_some() {
cx.propagate();
return;
}
self.jump_to_prompt();
self.close_completion();
self.cursor_visible = true;
self.cmd.insert_str("\n");
self.history_nav = None;
self.editor_goal_col = None;
self.last_word_nav = None;
cx.notify();
}
fn accept_line(&mut self, cx: &mut Context<Self>) {
if self
.completion
.as_ref()
.is_some_and(|s| s.selected().is_some())
{
self.completion_accept(cx);
return;
}
self.close_completion();
self.submit_command(cx);
}
fn submit_command(&mut self, cx: &mut Context<Self>) {
if self.terminal.exited || !self.accepts_input(cx) {
return;
}
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
let line = self.cmd.text();
if !line.trim().is_empty() {
let cwd = self.cwd();
let now = unix_now();
*self.history_counts.entry(line.clone()).or_insert(0) += 1;
if let Some(dir) = cwd.as_ref().and_then(|p| p.to_str()) {
self.history_cwds
.entry(line.clone())
.or_default()
.insert(dir.to_string());
}
self.history_meta.insert(
line.clone(),
super::history::EntryMeta {
ts: Some(now),
exit: None,
},
);
if self.history.last().map(String::as_str) != Some(line.as_str()) {
self.history.push(line.clone());
}
self.flush_pending_history();
self.pending_history = Some(PendingHistory {
line: line.clone(),
cwd: cwd.clone(),
ts: now,
seq: self.terminal.prompt_seq(),
});
self.rerank_history(cwd.as_deref());
}
self.history_nav = None;
self.history_stash.clear();
self.close_completion();
self.wipe_pending_typeahead();
let bracketed = self
.terminal
.term
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
self.terminal.write(submit_bytes(&line, bracketed));
self.cmd.clear();
self.cursor_visible = true;
self.jump_to_prompt();
cx.notify();
}
fn insert_last_word(&mut self, cx: &mut Context<Self>) {
let resumed = self.last_word_nav.take().filter(|walk| {
let len = walk.word.chars().count();
self.cmd.cursor() == walk.at + len
&& self.cmd.selection().is_none()
&& self
.cmd
.text()
.chars()
.skip(walk.at)
.take(len)
.eq(walk.word.chars())
});
let start = match &resumed {
Some(walk) => walk.entry.checked_sub(1),
None => self.history.len().checked_sub(1),
};
let Some(mut entry) = start else {
self.last_word_nav = resumed;
return;
};
let word = loop {
if let Some(w) = self.history[entry].split_whitespace().next_back() {
break w.to_string();
}
let Some(older) = entry.checked_sub(1) else {
self.last_word_nav = resumed;
return;
};
entry = older;
};
if let Some(walk) = resumed {
self.cmd.clear_selection();
self.cmd.set_cursor(walk.at);
self.cmd.extend_to(walk.at + walk.word.chars().count());
self.cmd.delete_selection();
}
self.cmd.insert_str(&word);
let at = self.cmd.cursor() - word.chars().count();
self.last_word_nav = Some(LastWordWalk { entry, at, word });
self.history_nav = None;
cx.notify();
}
fn history_prev(&mut self, cx: &mut Context<Self>) {
if self.history.is_empty() {
return;
}
let next = match self.history_nav {
None => {
self.history_stash = self.cmd.text();
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_nav = Some(next);
self.cmd.set(&self.history[next]);
cx.notify();
}
fn history_next(&mut self, cx: &mut Context<Self>) {
let Some(i) = self.history_nav else {
return;
};
if i + 1 < self.history.len() {
self.history_nav = Some(i + 1);
self.cmd.set(&self.history[i + 1]);
} else {
self.history_nav = None;
let stash = std::mem::take(&mut self.history_stash);
self.cmd.set(&stash);
}
cx.notify();
}
fn rerank_history(&mut self, cwd: Option<&std::path::Path>) {
let cwd_str = cwd.and_then(|p| p.to_str());
self.history_ranked = super::history::rank_by_frecency(
&self.history,
&self.history_counts,
&self.history_cwds,
cwd_str,
);
self.history_frecency = super::history::frecency_scores(
&self.history,
&self.history_counts,
&self.history_cwds,
cwd_str,
);
self.ranked_cwd = cwd.map(std::path::Path::to_path_buf);
}
fn flush_pending_history(&mut self) {
let Some(p) = self.pending_history.take() else {
return;
};
let exit = (self.terminal.prompt_seq() > p.seq && self.terminal.at_prompt())
.then(|| self.terminal.last_exit_code())
.flatten();
if exit.is_some()
&& let Some(m) = self.history_meta.get_mut(&p.line)
{
m.exit = exit;
}
super::history::append(&self.history_scope, &p.line, p.cwd.as_deref(), p.ts, exit);
}
fn ghost_suggestion(&self) -> Option<String> {
if self.cmd.is_empty() || self.cmd.cursor() != self.cmd.len() {
return None;
}
let line = self.cmd.text();
self.history_ranked
.iter()
.find(|h| h.len() > line.len() && h.starts_with(&line))
.cloned()
}
fn note_integration_gap(&mut self, cx: &mut Context<Self>) {
if self.integration_notice_shown
|| self.terminal.shell_active()
|| self.on_alt_screen()
|| self.created_at.elapsed() < INTEGRATION_GRACE
{
return;
}
self.integration_notice_shown = true;
self.integration_notice = Some(integration_notice_message(None));
cx.notify();
let pane_id = self.pane_id;
let route = self.pane_route();
cx.spawn(async move |this, cx| {
let fg = cx
.background_executor()
.spawn(async move {
RemoteTerminal::list_panes_on(&route)
.into_iter()
.find(|p| p.pane_id == pane_id)
.map(|p| p.title)
})
.await;
if let Some(shim) = fg.as_deref().and_then(known_pty_shim) {
let _ = this.update(cx, |view, cx| {
if view.integration_notice.is_some() {
view.integration_notice = Some(integration_notice_message(Some(shim)));
cx.notify();
}
});
}
cx.background_executor()
.timer(INTEGRATION_NOTICE_TIMEOUT)
.await;
let _ = this.update(cx, |view, cx| {
if view.integration_notice.take().is_some() {
cx.notify();
}
});
})
.detach();
}
fn start_reverse_search(&mut self) {
if self.reverse_search.is_none() {
self.reverse_search = Some(ReverseSearch::new(&self.history, &self.history_frecency));
}
}
fn handle_reverse_search_key(&mut self, ks: &gpui::Keystroke, cx: &mut Context<Self>) {
let m = &ks.modifiers;
if !m.control && !m.platform && !m.alt {
if let Some(ch) = ks.key_char.as_deref() {
if !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}') {
if let Some(rs) = self.reverse_search.as_mut() {
rs.push_query(ch, &self.history, &self.history_frecency);
}
cx.notify();
return;
}
}
}
let Some(rs) = self.reverse_search.as_mut() else {
return;
};
match rs.handle_key(ks, &self.history, &self.history_frecency) {
reverse_search::Action::Redraw => {}
reverse_search::Action::Cancel => self.reverse_search = None,
reverse_search::Action::Accept(line) => {
self.reverse_search = None;
if let Some(line) = line {
self.cmd.set(&line);
}
}
reverse_search::Action::Run(line) => {
self.reverse_search = None;
self.cmd.set(&line);
self.submit_command(cx);
}
}
cx.notify();
}
fn handoff_line_to_shell(&mut self, chord: &[u8], cx: &mut Context<Self>) {
if !self.accepts_input(cx) {
return;
}
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
let line = self.cmd.text();
if line.contains('\n') {
cx.notify();
return;
}
self.close_completion();
self.wipe_pending_typeahead();
let tail = line.chars().count().saturating_sub(self.cmd.cursor());
if !line.is_empty() {
self.terminal.write(line.into_bytes());
if tail > 0 {
self.terminal.write(b"\x1b[D".repeat(tail));
}
}
self.cmd.clear();
self.editor_handoff = Some(self.terminal.prompt_cycle());
self.send_to_pty(chord, cx);
}
fn tab_pressed(&mut self, forward: bool, cx: &mut Context<Self>) {
if self.search_focused {
cx.propagate();
return;
}
if let Some(reason) = self.link_inactive_reason(cx) {
log::debug!(target: "tty7::completion", "Tab does nothing and the line stays: {reason}");
return;
}
if let Some(reason) = self.input_inactive_reason() {
log::debug!(target: "tty7::completion", "Tab goes straight to the PTY: {reason}");
let bytes = self.tab_bytes(!forward);
self.send_to_pty(&bytes, cx);
return;
}
self.complete_tab(forward, cx);
}
fn handoff_tab_to_shell(&mut self, shift: bool, cx: &mut Context<Self>) {
let bytes = self.tab_bytes(shift);
self.handoff_line_to_shell(&bytes, cx);
}
fn complete_tab(&mut self, forward: bool, cx: &mut Context<Self>) {
if self.reverse_search.is_some() {
return;
}
if !cx.global::<Config>().tab_completion {
log::debug!(target: "tty7::completion", "handing the line to the shell: tab_completion is off");
self.handoff_tab_to_shell(!forward, cx);
return;
}
if self.completion.is_some() {
self.completion_tab_step(forward, cx);
return;
}
let cwd = self
.paths_are_local()
.then(|| self.local_cwd().or_else(|| std::env::current_dir().ok()))
.flatten();
let line = self.cmd.text();
let cursor = self.cmd.cursor();
let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else {
if self.spawn_remote_path_completion(&line, cursor, forward, cx) {
return;
}
log::debug!(
target: "tty7::completion",
"handing the line to the shell: no candidates for {line:?} at {cursor} \
(local cwd {cwd:?}, remote cwd {:?})",
self.remote_ssh_cwd(),
);
self.handoff_tab_to_shell(!forward, cx);
return;
};
let has_pending = !comp.pending.is_empty();
let (word_start, word_end) = match comp.candidates.first() {
Some(c) => (c.start, c.end),
None => (word_start_of(&line, cursor), cursor),
};
let Some(generation) = self.offer_candidates(
&line,
word_start,
word_end,
comp.candidates,
has_pending,
cx,
) else {
return;
};
let Some(cwd) = cwd else { return };
for pending in comp.pending {
let script = pending.script;
let cwd = cwd.clone();
cx.spawn(async move |this, cx| {
let results = cx
.background_executor()
.spawn(async move { super::generator::run(&script, &cwd) })
.await;
if results.is_empty() {
return;
}
let _ = this.update(cx, |view, cx| {
view.completion_merge(generation, results, cx);
});
})
.detach();
}
}
fn offer_candidates(
&mut self,
line: &str,
word_start: usize,
word_end: usize,
cands: Vec<completion::Candidate>,
has_pending: bool,
cx: &mut Context<Self>,
) -> Option<u64> {
if !has_pending && cands.len() == 1 {
let c = cands[0].clone();
self.completion_insert(&c, c.start);
self.cursor_visible = true;
cx.notify();
return None;
}
let word: String = line
.chars()
.skip(word_start)
.take(word_end - word_start)
.collect();
let s = CompletionSession::new(word_start, word.clone(), cands);
if !has_pending
&& let Some(lcp) = s.common_prefix()
&& lcp.chars().count() > word.chars().count()
{
self.apply_candidate(line, word_start, word_end, &lcp);
}
let generation = self.open_completion(s);
self.cursor_visible = true;
cx.notify();
Some(generation)
}
fn remote_ssh_cwd(&self) -> Option<String> {
let owned = match self.terminal.remote_context() {
Some(remote) => remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh,
None => self.workspace.is_some(),
};
if !owned {
return None;
}
let cwd = self.cwd()?.to_string_lossy().into_owned();
cwd.starts_with('/').then_some(cwd)
}
fn spawn_remote_path_completion(
&mut self,
line: &str,
cursor: usize,
forward: bool,
cx: &mut Context<Self>,
) -> bool {
let Some(cwd) = self.remote_ssh_cwd() else {
return false;
};
let Some(req) = completion::remote_path_request(line, cursor, &cwd) else {
log::debug!(
target: "tty7::completion",
"no remote listing to ask for: {line:?} at {cursor} against {cwd}"
);
return false;
};
if self.remote_completion_inflight {
return true;
}
self.remote_completion_inflight = true;
let route = crate::ui::sftp::SftpRoute::new(self.pane_id, self.workspace.clone());
let dir = req.dir.clone();
let line = line.to_string();
log::debug!(target: "tty7::completion", "listing {dir} over the remote's own connection");
cx.spawn(async move |this, cx| {
let listed = cx.background_spawn(async move { route.list(&dir) }).await;
let entries = listed.unwrap_or_else(|e| {
log::warn!(
target: "tty7::completion",
"remote listing failed, treating it as no candidates: {e}"
);
Vec::new()
});
let _ = this.update(cx, |view, cx| {
view.remote_completion_inflight = false;
view.remote_path_results(req, &line, cursor, entries, forward, cx);
});
})
.detach();
true
}
fn remote_path_results(
&mut self,
req: completion::RemotePathRequest,
line: &str,
cursor: usize,
listed: Vec<crate::daemon::protocol::SftpEntry>,
forward: bool,
cx: &mut Context<Self>,
) {
if let Some(reason) = self
.link_inactive_reason(cx)
.or_else(|| self.input_inactive_reason())
{
log::debug!(
target: "tty7::completion",
"dropping a remote listing for {line:?}: {reason}"
);
return;
}
if self.cmd.text() != line || self.cmd.cursor() != cursor {
log::debug!(
target: "tty7::completion",
"dropping a remote listing for {line:?}: the line has moved on"
);
return;
}
let entries: Vec<completion::RemoteEntry> = listed
.into_iter()
.map(|e| completion::RemoteEntry {
is_dir: e.kind == crate::daemon::protocol::SftpEntryKind::Dir || e.target_is_dir,
name: e.name,
})
.collect();
let cands = completion::remote_path_candidates(&req, &entries);
log::debug!(
target: "tty7::completion",
"{} entries in {}, {} match the word",
entries.len(),
req.dir,
cands.len()
);
if cands.is_empty() {
self.handoff_tab_to_shell(!forward, cx);
return;
}
self.offer_candidates(line, req.word_start, req.cursor, cands, false, cx);
}
fn open_completion(&mut self, session: CompletionSession) -> u64 {
self.completion = Some(session);
self.completion_generation = self.completion_generation.wrapping_add(1);
self.completion_generation
}
fn close_completion(&mut self) {
let _ = self.take_completion();
}
fn take_completion(&mut self) -> Option<CompletionSession> {
let s = self.completion.take();
if s.is_some() {
self.completion_generation = self.completion_generation.wrapping_add(1);
}
s
}
fn completion_merge(
&mut self,
generation: u64,
results: Vec<super::generator::Parsed>,
cx: &mut Context<Self>,
) {
if self.completion_generation != generation || self.completion.is_none() {
return;
}
let word_start = self.completion.as_ref().map(|s| s.word_start).unwrap_or(0);
let chars: Vec<char> = self.cmd.text().chars().collect();
let cursor = self.cmd.cursor().min(chars.len());
let end = cursor.max(word_start);
let live_word: String = if cursor >= word_start {
chars[word_start..cursor].iter().collect()
} else {
String::new()
};
let new: Vec<completion::Candidate> = results
.into_iter()
.map(|p| completion::Candidate {
text: p.text,
kind: CandidateKind::Value,
start: word_start,
end,
description: p.description,
icon: None,
})
.collect();
if let Some(s) = self.completion.as_mut() {
s.merge(new, &live_word);
cx.notify();
}
}
fn completion_tab_step(&mut self, forward: bool, cx: &mut Context<Self>) {
if forward {
let Some(s) = self.completion.as_ref() else {
return;
};
let (word_start, lcp, lone) = (s.word_start, s.common_prefix(), s.filtered.len() == 1);
let line = self.cmd.text();
let cursor = self.cmd.cursor().min(line.chars().count());
if let Some(lcp) = lcp
&& lcp.chars().count() > cursor.saturating_sub(word_start)
{
if lone {
self.completion_accept(cx);
} else {
self.apply_candidate(&line, word_start, cursor, &lcp);
self.cursor_visible = true;
cx.notify();
}
return;
}
}
self.completion_select(forward, cx);
}
fn completion_select(&mut self, forward: bool, cx: &mut Context<Self>) {
if let Some(s) = self.completion.as_mut() {
s.select(forward);
self.cursor_visible = true;
cx.notify();
}
}
fn completion_accept(&mut self, cx: &mut Context<Self>) {
let Some(s) = self.take_completion() else {
return;
};
if let Some(c) = s.selected().cloned() {
self.completion_insert(&c, s.word_start);
}
self.cursor_visible = true;
cx.notify();
}
fn completion_insert(&mut self, cand: &completion::Candidate, start: usize) {
let line = self.cmd.text();
let len = line.chars().count();
let cursor = self.cmd.cursor().min(len);
let mut text = cand.text.clone();
if cand.is_dir() {
if !text.ends_with('/') {
text.push('/');
}
} else if cursor == len {
text.push(' ');
}
self.apply_candidate(&line, start, cursor, &text);
}
fn completion_refilter(&mut self) {
let Some(s) = self.completion.as_mut() else {
return;
};
let chars: Vec<char> = self.cmd.text().chars().collect();
let cursor = self.cmd.cursor().min(chars.len());
let keep = cursor >= s.word_start
&& chars[s.word_start..cursor]
.iter()
.all(|c| !c.is_whitespace())
&& {
let word: String = chars[s.word_start..cursor].iter().collect();
s.refilter(&word)
};
if !keep {
self.close_completion();
}
}
fn apply_candidate(&mut self, orig: &str, start: usize, end: usize, text: &str) {
let (line, cursor) = completion::Replacement {
orig: orig.to_string(),
start,
end,
text: text.to_string(),
}
.apply();
self.cmd.set_with_cursor(&line, cursor);
}
pub fn input_text(&mut self, text: &str, cx: &mut Context<Self>) {
self.commit_text(text, cx);
}
pub fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
if self.terminal.exited || text.is_empty() || !self.accepts_input(cx) {
return;
}
if let Some(rs) = self.reverse_search.as_mut() {
rs.push_query(text, &self.history, &self.history_frecency);
self.cursor_visible = true;
cx.notify();
return;
}
if self.input_active() {
self.cmd.insert_str(text);
self.history_nav = None;
self.editor_goal_col = None;
self.last_word_nav = None;
self.completion_refilter();
self.cursor_visible = true;
cx.notify();
return;
}
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
self.cursor_visible = true;
self.jump_to_prompt();
cx.notify();
}
pub fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
self.marked_text = text;
cx.notify();
}
pub fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
if !self.marked_text.is_empty() {
self.marked_text.clear();
cx.notify();
}
}
pub fn on_select_start(
&mut self,
col: usize,
row: usize,
left: bool,
clicks: usize,
shift: bool,
cx: &mut Context<Self>,
) {
let smart = cx.global::<Config>().smart_select;
let mut term = self.terminal.term.lock();
let display_offset = term.grid().display_offset() as i32;
let point = Point::new(Line(row as i32 - display_offset), Column(col));
let side = if left { Side::Left } else { Side::Right };
if shift && clicks == 1 && term.selection.is_some() {
if let Some(sel) = term.selection.as_mut() {
sel.update(point, side);
}
drop(term);
self.selecting = true;
cx.notify();
return;
}
let ty = match clicks {
2 => SelectionType::Semantic,
n if n >= 3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
let mut selection = Selection::new(ty, point, side);
if clicks == 2
&& smart
&& let Some(r) = super::smart_select::grid_smart_range(&term, point)
{
let ty = if r.exact {
SelectionType::Simple
} else {
SelectionType::Semantic
};
selection = Selection::new(ty, r.start, Side::Left);
selection.update(r.end, Side::Right);
}
term.selection = Some(selection);
drop(term);
self.selecting = true;
cx.notify();
}
pub fn on_select_update(&mut self, col: usize, row: usize, left: bool, cx: &mut Context<Self>) {
if !self.selecting {
return;
}
let mut term = self.terminal.term.lock();
let display_offset = term.grid().display_offset() as i32;
let point = Point::new(Line(row as i32 - display_offset), Column(col));
let side = if left { Side::Left } else { Side::Right };
if let Some(sel) = term.selection.as_mut() {
sel.update(point, side);
}
drop(term);
cx.notify();
}
pub fn select_autoscroll(
&mut self,
overshoot: f32,
col: usize,
left: bool,
cx: &mut Context<Self>,
) {
if !self.selecting || overshoot == 0. {
self.drag_scroll = None;
return;
}
let side = if left { Side::Left } else { Side::Right };
let was_idle = self.drag_scroll.is_none();
self.drag_scroll = Some(DragScroll {
overshoot,
col,
side,
});
if !was_idle {
return;
}
self.drag_scroll_epoch += 1;
let epoch = self.drag_scroll_epoch;
self.drag_scroll_tick(epoch, cx);
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(50))
.await;
if !matches!(
this.update(cx, |view, cx| view.drag_scroll_tick(epoch, cx)),
Ok(true)
) {
break;
}
}
})
.detach();
}
fn drag_scroll_tick(&mut self, epoch: u64, cx: &mut Context<Self>) -> bool {
if epoch != self.drag_scroll_epoch {
return false;
}
if !self.selecting {
self.drag_scroll = None;
}
let Some(ds) = self.drag_scroll else {
return false;
};
let mut term = self.terminal.term.lock();
let before = term.grid().display_offset();
term.scroll_display(Scroll::Delta(drag_scroll_step(ds.overshoot)));
let offset = term.grid().display_offset();
let row = if ds.overshoot > 0. {
0
} else {
term.screen_lines().saturating_sub(1)
};
let point = Point::new(Line(row as i32 - offset as i32), Column(ds.col));
if let Some(sel) = term.selection.as_mut() {
sel.update(point, ds.side);
}
drop(term);
if offset != before {
self.scroll_frac = 0.;
cx.notify();
}
true
}
pub fn on_select_end(&mut self, cx: &mut Context<Self>) {
let copy = select_end_copy(
cx.global::<Config>().copy_on_select,
self.selecting,
self.editor_select_gesture,
);
self.selecting = false;
self.editor_selecting = false;
self.editor_select_gesture = false;
self.editor_drag_word = None;
self.drag_scroll = None;
match copy {
SelectEndCopy::None => {}
SelectEndCopy::Grid => self.copy_selection(cx),
SelectEndCopy::Editor => {
if let Some(text) = self.cmd.selected_text() {
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}
}
}
fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
let mult = cx.global::<Config>().mouse_scroll_multiplier;
let raw = match ev.delta {
ScrollDelta::Lines(p) => p.y,
ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(),
};
let delta = raw * mult;
let quantized = !ev.modifiers.shift && {
let mode = *self.terminal.term.lock().mode();
mode.intersects(TermMode::MOUSE_MODE)
|| mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
};
if quantized {
let total = self.scroll_debt + delta;
let lines = total.trunc() as i32;
self.scroll_debt = total - lines as f32;
if lines != 0 {
self.scroll(lines, &ev.modifiers, cx);
}
return;
}
self.smooth_scroll(delta, cx);
}
pub fn command_marks(&self) -> Vec<crate::terminal::marks::CommandMark> {
self.terminal.marks().list()
}
pub fn scroll_to_mark(&mut self, row: i64, cx: &mut Context<Self>) -> bool {
use alacritty_terminal::grid::Dimensions as _;
let mut term = self.terminal.term.lock();
let history = term.grid().history_size() as i64;
if row < 0 || row > history + term.grid().screen_lines() as i64 {
return false;
}
let target = (history - row).max(0);
let current = term.grid().display_offset() as i64;
term.scroll_display(Scroll::Delta((target - current) as i32));
drop(term);
self.scroll_frac = 0.;
cx.notify();
true
}
fn smooth_scroll(&mut self, delta: f32, cx: &mut Context<Self>) {
let mut term = self.terminal.term.lock();
let offset = term.grid().display_offset();
let max = term.grid().history_size();
let (jump, frac) = smooth_scroll_step(offset, self.scroll_frac, delta, max);
if jump != 0 {
term.scroll_display(Scroll::Delta(jump));
}
drop(term);
if jump != 0 || frac != self.scroll_frac {
self.scroll_frac = frac;
cx.notify();
}
}
fn grid_line(
term: &alacritty_terminal::Term<crate::terminal::remote::EventProxy>,
row: usize,
) -> Option<Line> {
let line = Line(row as i32 - term.grid().display_offset() as i32);
(line >= term.topmost_line() && line <= term.bottommost_line()).then_some(line)
}
pub fn open_link_at(
&self,
col: usize,
row: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if !cx.global::<Config>().link_url {
return false;
}
let include_loopback = self.can_forward_loopback(cx);
let Some((target, _start, _end)) = self.resolve_link_at(col, row, true, include_loopback)
else {
return false;
};
match target {
LinkTarget::Url(url) => self.open_url(&url, window, cx),
LinkTarget::File { path, line, column } => {
match cx.global::<Config>().link_file_command.as_deref() {
Some(template) => run_file_command(template, &path, line, column),
None => open_file_path(&path),
}
}
}
true
}
fn open_url(&self, url: &str, window: &mut Window, cx: &mut Context<Self>) {
match self.forwarded_loopback_url(url, cx) {
LoopbackOpen::Forwarded(url) => cx.open_url(&url),
LoopbackOpen::NotLoopback => cx.open_url(url),
LoopbackOpen::ForwardFailed(reason) => {
window.push_notification(reason, cx);
}
}
}
fn forwarded_loopback_url(&self, url: &str, cx: &mut Context<Self>) -> LoopbackOpen {
let plan = self.loopback_plan(cx);
if matches!(plan, LoopbackPlan::Direct) {
return LoopbackOpen::NotLoopback;
}
let Some(loopback) = super::loopback::parse_loopback_url(url) else {
return LoopbackOpen::NotLoopback;
};
if matches!(plan, LoopbackPlan::NoForwardNeeded) {
return LoopbackOpen::NotLoopback;
}
let forwarded = match &plan {
LoopbackPlan::ForwardOnPane(pane_id) => RemoteTerminal::ensure_loopback_forward(
*pane_id,
loopback.forward_host(),
loopback.port,
),
LoopbackPlan::ForwardOnWorkspace(ws) => self.ensure_workspace_loopback(ws, &loopback),
LoopbackPlan::Direct | LoopbackPlan::NoForwardNeeded => unreachable!("handled above"),
};
match forwarded {
Ok(forward) => LoopbackOpen::Forwarded(loopback.forwarded_url(forward.local_port)),
Err(e) => {
log::warn!("failed to forward loopback URL {url}: {e}");
LoopbackOpen::ForwardFailed(format!("Couldn't forward :{}{e}", loopback.port))
}
}
}
fn ensure_workspace_loopback(
&self,
ws: &crate::terminal::PaneWorkspace,
loopback: &super::loopback::LoopbackUrl,
) -> anyhow::Result<crate::daemon::protocol::LoopbackForward> {
let req = RemoteTerminal::workspace_request(
ws,
self.pane_id,
crate::daemon::protocol::WorkspaceOp::EnsureLoopback {
remote_host: loopback.forward_host().to_string(),
remote_port: loopback.port,
},
)
.ok_or_else(|| anyhow::anyhow!("this workspace has no SSH connection to forward over"))?;
match RemoteTerminal::on_workspace(req)? {
crate::daemon::protocol::DaemonMsg::LoopbackForward(f) => Ok(f),
other => Err(anyhow::anyhow!("unexpected reply: {other:?}")),
}
}
fn loopback_plan(&self, cx: &mut Context<Self>) -> LoopbackPlan {
loopback_plan(
cx.global::<Config>().ssh_loopback_forward,
self.workspace.as_ref(),
self.terminal.remote_context().map(|r| r.kind),
self.pane_id,
)
}
fn can_forward_loopback(&self, cx: &mut Context<Self>) -> bool {
!matches!(self.loopback_plan(cx), LoopbackPlan::Direct)
}
pub fn hover_link_at(
&mut self,
col: usize,
row: usize,
include_files: bool,
cx: &mut Context<Self>,
) -> bool {
self.last_hover_cell = Some((col, row));
if !cx.global::<Config>().link_url {
self.clear_hovered_link(cx);
return false;
}
let include_loopback = self.can_forward_loopback(cx);
let next = self.link_span_at(col, row, include_files, include_loopback);
if next != self.hovered_link {
self.hovered_link = next;
cx.notify();
}
self.hovered_link.is_some()
}
pub fn refresh_link_hover(&mut self, include_files: bool, cx: &mut Context<Self>) -> bool {
self.link_modifier_down = include_files;
let Some((col, row)) = self.last_hover_cell else {
return false;
};
self.hover_link_at(col, row, include_files, cx)
}
pub fn link_modifier_down(&self) -> bool {
self.link_modifier_down
}
pub fn clear_hovered_link(&mut self, cx: &mut Context<Self>) {
self.last_hover_cell = None;
if self.hovered_link.take().is_some() {
cx.notify();
}
}
fn link_span_at(
&self,
col: usize,
row: usize,
include_files: bool,
include_loopback: bool,
) -> Option<HoveredLink> {
self.resolve_link_at(col, row, include_files, include_loopback)
.map(|(_, start, end)| HoveredLink { start, end })
}
fn resolve_link_at(
&self,
col: usize,
row: usize,
include_files: bool,
include_loopback: bool,
) -> Option<(LinkTarget, Point, Point)> {
let term = self.terminal.term.lock();
let line = Self::grid_line(&term, row)?;
let cols = term.columns();
if col >= cols {
return None;
}
let click = Point::new(line, Column(col));
if let Some(hl) = term.grid()[line][Column(col)].hyperlink() {
let uri = hl.uri().to_string();
if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) {
return Some((LinkTarget::Url(uri), start, end));
}
}
let (text, points, click_idx) = super::smart_select::logical_line_at(&term, click, true)?;
drop(term);
let cwd = self.local_cwd();
let link = super::search::link_at(&text, click_idx, cwd.as_deref(), include_files)
.or_else(|| {
include_loopback.then(|| {
super::loopback::loopback_url_span_at(&text, click_idx).map(
|(start, end, url)| super::search::LinkMatch {
start,
end,
target: LinkTarget::Url(url),
},
)
})?
})?;
Some((link.target, points[link.start], points[link.end]))
}
fn render_input_bar(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let (crow, ccol) = self.cursor_cell().unwrap_or((0, 0));
let cx_left = px(GRID_PAD_X) + self.cell_width * (ccol as f32);
let shift = self.input_scroll_rows();
let cy_top = px(GRID_PAD_Y) + self.line_height * (crow as f32 - shift as f32);
if let Some(rs) = &self.reverse_search {
let label = format!("(reverse-i-search)`{}': ", rs.query());
let matched = rs
.selected_line(&self.history)
.unwrap_or_default()
.to_string();
return div()
.absolute()
.left(cx_left)
.top(cy_top)
.right_4()
.h(self.line_height)
.flex()
.items_center()
.font_family(self.font.family.clone())
.text_size(self.font_size)
.child(
div()
.whitespace_nowrap()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(
div()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(matched),
);
}
let chars: Vec<char> = self.cmd.text().chars().collect();
let len = chars.len();
let cursor = self.cmd.cursor();
let marked = self.marked_text.clone();
let has_marked = !marked.is_empty();
let selection = self.cmd.selection();
let theme = cx.theme();
let fg = theme.foreground;
let caret_col = theme.caret;
let muted = theme.muted_foreground;
let mut sel_bg = theme.selection;
sel_bg.a = 0.55;
let cell_w = self.cell_width;
let lh = self.line_height;
let caret_h = px((self.font_size.as_f32() * 1.2).min(lh.as_f32()));
let caret_top = px((lh.as_f32() - caret_h.as_f32()) / 2.0);
let line: String = chars.iter().collect();
let mut colors: Vec<gpui::Hsla> = Vec::with_capacity(len);
for span in highlight::highlight(&line) {
let c = self.kind_color(span.kind, cx);
for _ in span.text.chars() {
colors.push(c);
}
}
let cursor_on = self.cursor_visible;
let cursor_style = cx.global::<Config>().cursor_style;
let caret_bar = move || {
use crate::core::config::CursorStyle;
let base = div().absolute().left_0().bg(caret_col);
match cursor_style {
CursorStyle::Bar => base.top(caret_top).w(px(1.5)).h(caret_h),
CursorStyle::Block => base.top(px(0.)).w_full().h(lh).bg(caret_col.opacity(0.5)),
CursorStyle::Underline => {
let uh = px(2.);
base.top(lh - uh).w_full().h(uh)
}
}
};
let cell = |color: gpui::Hsla, ch: char, selected: bool, caret: bool, underline: bool| {
let w = cell_w * (display_width(ch) as f32);
let mut d = div()
.relative()
.flex_none()
.w(w)
.h(lh)
.flex()
.items_center()
.text_color(color);
if selected {
d = d.bg(sel_bg);
}
if underline {
d = d.border_b_1().border_color(fg);
}
d = d.child(ch.to_string());
if caret {
d = d.child(caret_bar());
}
d.into_any_element()
};
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
let mut lines: Vec<Vec<gpui::AnyElement>> =
vec![vec![blank(cell_w * (ccol as f32)).into_any_element()]];
let is_multiline = chars.contains(&'\n');
for i in 0..len {
if i == cursor && has_marked {
for mc in marked.chars() {
lines
.last_mut()
.unwrap()
.push(cell(fg, mc, false, false, true));
}
}
if chars[i] == '\n' {
if selection.is_none() && !has_marked && cursor_on && cursor == i {
lines.last_mut().unwrap().push(
blank(cell_w)
.relative()
.child(caret_bar())
.into_any_element(),
);
} else if selection.is_some_and(|(s, e)| i >= s && i < e) {
lines
.last_mut()
.unwrap()
.push(blank(cell_w).bg(sel_bg).into_any_element());
}
lines.push(Vec::new());
continue;
}
let selected = selection.is_some_and(|(s, e)| i >= s && i < e);
let caret = selection.is_none() && !has_marked && cursor_on && cursor == i;
lines
.last_mut()
.unwrap()
.push(cell(colors[i], chars[i], selected, caret, false));
}
let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline {
self.ghost_suggestion()
.map(|full| full.chars().skip(len).collect::<String>())
.filter(|r| !r.is_empty())
} else {
None
};
if cursor == len {
let last = lines.last_mut().unwrap();
if has_marked {
for mc in marked.chars() {
last.push(cell(fg, mc, false, false, true));
}
} else if ghost.is_none() {
let mut tail = blank(cell_w).relative();
if selection.is_none() && cursor_on {
tail = tail.child(caret_bar());
}
last.push(tail.into_any_element());
}
}
if let Some(rem) = ghost {
let last = lines.last_mut().unwrap();
for (gi, gc) in rem.chars().enumerate() {
let caret = gi == 0 && cursor == len && cursor_on;
last.push(cell(muted, gc, false, caret, false));
}
}
let rows = lines.into_iter().map(move |cells| {
div()
.flex()
.flex_wrap()
.items_center()
.w_full()
.min_h(lh)
.children(cells)
});
div()
.absolute()
.left(px(GRID_PAD_X))
.top(cy_top)
.right_4()
.min_h(lh)
.flex()
.flex_col()
.font_family(self.font.family.clone())
.text_size(self.font_size)
.line_height(lh)
.text_color(fg)
.children(rows)
}
fn render_completion_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
let s = self.completion.as_ref()?;
let items: Vec<&completion::Candidate> = s.filtered.iter().map(|&i| &s.all[i]).collect();
if items.is_empty() {
return None;
}
let (srow, scol) = self.cursor_cell()?;
let srow = srow.saturating_sub(self.input_scroll_rows());
const MAX_ROWS: usize = 10;
let total_rows = self.terminal.term.lock().screen_lines();
let (place_above, visible, first) = menu_layout(
total_rows,
srow,
items.len(),
s.index.unwrap_or(0),
MAX_ROWS,
);
let hidden_above = first;
let hidden_below = items.len() - first - visible;
let theme = cx.theme();
let lh = self.line_height;
let row = |i: usize| {
let cand = items[i];
let selected = s.index == Some(i);
let icon_color = if selected {
theme.foreground
} else {
theme.muted_foreground
};
let icon = completion_row_icon(cand.icon.as_deref(), cand.kind, icon_color);
let label = if cand.is_dir() && !cand.text.ends_with('/') {
format!("{}/", cand.text)
} else {
cand.text.clone()
};
div()
.h(lh)
.flex()
.items_center()
.gap_1p5()
.px_2()
.whitespace_nowrap()
.when(selected, |d| {
d.bg(theme.list_active).text_color(theme.foreground)
})
.child(icon)
.child(div().flex_shrink_0().child(label))
.when_some(cand.description.clone(), |d, desc| {
d.child(div().ml_2().text_color(theme.muted_foreground).child(desc))
})
.into_any_element()
};
let rows: Vec<gpui::AnyElement> = (first..first + visible).map(row).collect();
let footer = |n: usize, label: String| {
(n > 0).then(|| {
div()
.h(lh)
.flex()
.items_center()
.px_2()
.text_color(theme.muted_foreground)
.child(label)
.into_any_element()
})
};
let footer_lines = (hidden_above > 0) as usize + (hidden_below > 0) as usize;
let line_count = visible + footer_lines;
let menu_h = self.line_height * (line_count as f32) + px(10.);
let gap = px(6.);
let x = px(GRID_PAD_X) + self.cell_width * (scol as f32);
let y = if place_above {
px(GRID_PAD_Y) + self.line_height * (srow as f32) - menu_h - gap
} else {
px(GRID_PAD_Y) + self.line_height * ((srow + 1) as f32) + gap
};
Some(
div()
.absolute()
.left(x)
.top(y)
.flex()
.flex_col()
.py_1()
.min_w(px(120.))
.max_w(px(480.))
.overflow_hidden()
.bg(theme.popover)
.border_1()
.border_color(theme.border)
.rounded(px(6.))
.font_family(self.font.family.clone())
.text_size(self.font_size)
.text_color(theme.popover_foreground)
.children(footer(hidden_above, format!("↑ {hidden_above} more")))
.children(rows)
.children(footer(hidden_below, format!("↓ {hidden_below} more"))),
)
}
fn render_reverse_search_menu(
&self,
cx: &mut Context<Self>,
) -> Option<impl IntoElement + use<>> {
let rs = self.reverse_search.as_ref()?;
let matches = rs.matches();
if matches.is_empty() {
return None;
}
let (srow, _) = self.cursor_cell()?;
const MAX_ROWS: usize = 10;
let (total_rows, total_cols) = {
let term = self.terminal.term.lock();
(term.screen_lines(), term.columns())
};
let (place_above, visible, first) =
menu_layout(total_rows, srow, matches.len(), rs.selected(), MAX_ROWS);
let hidden_above = first;
let hidden_below = matches.len() - first - visible;
let theme = cx.theme();
let lh = self.line_height;
let now = unix_now();
let row = |i: usize| {
let m = &matches[i];
let line = self.history[m.index].as_str();
let selected = rs.selected() == i;
let base = if selected {
theme.foreground
} else {
theme.popover_foreground
};
let mut spans: Vec<gpui::AnyElement> = Vec::new();
let mut flush = |run: &mut String, hit: bool| {
if run.is_empty() {
return;
}
spans.push(
div()
.flex_none()
.whitespace_nowrap()
.text_color(if hit { theme.blue } else { base })
.child(std::mem::take(run))
.into_any_element(),
);
};
let mut pos = m.positions.iter().copied().peekable();
let mut run = String::new();
let mut run_hit = false;
for (ci, ch) in line.chars().enumerate() {
let hit = pos.next_if_eq(&ci).is_some();
if hit != run_hit {
flush(&mut run, run_hit);
run_hit = hit;
}
run.push(ch);
}
flush(&mut run, run_hit);
let meta = self.history_meta.get(line);
let failed = meta.and_then(|em| em.exit).filter(|&e| e != 0);
let ago = meta
.and_then(|em| em.ts)
.map(|ts| super::history::format_ago(now, ts));
div()
.h(lh)
.flex()
.items_center()
.gap_1p5()
.px_2()
.whitespace_nowrap()
.when(selected, |d| d.bg(theme.list_active))
.child(div().flex_1().flex().overflow_hidden().children(spans))
.when_some(failed, |d, code| {
d.child(
div()
.flex_none()
.text_color(theme.red)
.child(format!("✗ {code}")),
)
})
.when_some(ago, |d, ago| {
d.child(
div()
.flex_none()
.text_color(theme.muted_foreground)
.child(ago),
)
})
.into_any_element()
};
let rows: Vec<gpui::AnyElement> = (first..first + visible).map(row).collect();
let footer = |n: usize, label: String| {
(n > 0).then(|| {
div()
.h(lh)
.flex()
.items_center()
.px_2()
.text_color(theme.muted_foreground)
.child(label)
.into_any_element()
})
};
let footer_lines = (hidden_above > 0) as usize + (hidden_below > 0) as usize;
let line_count = visible + footer_lines;
let menu_h = lh * (line_count as f32) + px(10.);
let gap = px(6.);
let grid_w = self.cell_width * (total_cols as f32);
let menu_w = if grid_w < px(720.) { grid_w } else { px(720.) };
let y = if place_above {
px(GRID_PAD_Y) + lh * (srow as f32) - menu_h - gap
} else {
px(GRID_PAD_Y) + lh * ((srow + 1) as f32) + gap
};
Some(
div()
.absolute()
.left(px(GRID_PAD_X))
.top(y)
.flex()
.flex_col()
.py_1()
.w(menu_w)
.overflow_hidden()
.bg(theme.popover)
.border_1()
.border_color(theme.border)
.rounded(px(6.))
.font_family(self.font.family.clone())
.text_size(self.font_size)
.text_color(theme.popover_foreground)
.children(footer(hidden_above, format!("↑ {hidden_above} more")))
.children(rows)
.children(footer(hidden_below, format!("↓ {hidden_below} more"))),
)
}
fn render_integration_notice(
&self,
cx: &mut Context<Self>,
) -> Option<impl IntoElement + use<>> {
let text = self.integration_notice.clone()?;
let theme = cx.theme();
Some(
div()
.absolute()
.bottom(px(GRID_PAD_Y))
.right(px(GRID_PAD_X))
.max_w(px(560.))
.px_3()
.py_1()
.bg(theme.popover)
.border_1()
.border_color(theme.border)
.rounded(px(6.))
.text_size(px(12.))
.text_color(theme.muted_foreground)
.child(text),
)
}
fn kind_color(&self, kind: TokenKind, cx: &App) -> gpui::Hsla {
let theme = cx.theme();
match kind {
TokenKind::Command => theme.green,
TokenKind::Flag => theme.cyan,
TokenKind::Path => theme.blue,
TokenKind::StringLit => theme.yellow,
TokenKind::Operator => theme.magenta,
TokenKind::Comment => theme.muted_foreground,
TokenKind::Arg | TokenKind::Whitespace => theme.foreground,
}
}
}
impl Focusable for TerminalView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Drop for TerminalView {
fn drop(&mut self) {
self.flush_pending_history();
}
}
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.shell_owns_prompt() {
if let Some((_net, bytes)) = self.hold.release() {
self.terminal.write(bytes);
}
self.typeahead.drain();
} else if self.input_active() {
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
if self.terminal.zle_reading() {
self.flush_typeahead();
}
}
let entity = cx.entity();
let search_bar = self
.search
.as_ref()
.map(|s| self.render_search_bar(s, window, cx));
let input_bar = self.input_active().then(|| self.render_input_bar(cx));
let completion_menu = self
.input_active()
.then(|| self.render_completion_menu(cx))
.flatten();
let reverse_search_menu = self
.input_active()
.then(|| self.render_reverse_search_menu(cx))
.flatten();
let integration_notice = self.render_integration_notice(cx);
let menu_focus = self.focus_handle.clone();
let has_selection = self.any_selection();
let menu_view = cx.entity();
div()
.id("terminal-surface")
.track_focus(&self.focus_handle)
.key_context("Terminal")
.size_full()
.relative()
.overflow_hidden()
.px(px(GRID_PAD_X))
.py(px(GRID_PAD_Y))
.text_color(cx.theme().foreground)
.on_key_down(cx.listener(Self::on_key_down))
.on_scroll_wheel(cx.listener(Self::on_scroll))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _ev: &MouseDownEvent, window, cx| {
if window.default_prevented() {
return;
}
window.focus(&this.focus_handle, cx);
}),
)
.drag_over::<ExternalPaths>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.12)))
.on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| {
window.focus(&this.focus_handle, cx);
this.drop_files(paths, cx);
}))
.on_action(cx.listener(|this, _: &CopyText, _w, cx| {
this.copy_contextual(false, cx);
}))
.on_action(cx.listener(|this, _: &CutText, _w, cx| {
this.cut_contextual(cx);
}))
.on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx)))
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx)))
.on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx)))
.on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx)))
.on_action(
cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)),
)
.on_action(cx.listener(|this, _: &FindNext, _w, cx| {
this.step_match(Direction::Right, cx);
}))
.on_action(cx.listener(|this, _: &FindPrevious, _w, cx| {
this.step_match(Direction::Left, cx);
}))
.on_action(cx.listener(|this, _: &ClearScrollback, _w, cx| this.clear_scrollback(cx)))
.on_action(cx.listener(|this, _: &InsertNewline, _w, cx| {
this.insert_newline_action(cx);
}))
.on_action(cx.listener(|this, _: &SendTab, _w, cx| {
this.tab_pressed(true, cx);
}))
.on_action(cx.listener(|this, _: &SendBackTab, _w, cx| {
this.tab_pressed(false, cx);
}))
.child(TerminalElement::new(entity))
.children(search_bar)
.children(input_bar)
.children(completion_menu)
.children(reverse_search_menu)
.children(integration_notice)
.context_menu(move |menu, window, cx| {
let menu = menu
.min_w(px(220.))
.action_context(menu_focus.clone())
.menu_element_with_disabled(
Box::new(CopyText),
!has_selection,
menu_row_with_hint("Copy", Some("secondary-c")),
)
.menu_element_with_disabled(
Box::new(CutText),
!has_selection,
menu_row_with_hint("Cut", Some("secondary-x")),
)
.menu_element(
Box::new(PasteText),
menu_row_with_hint("Paste", Some("secondary-v")),
)
.menu_element(
Box::new(SelectAll),
menu_row_with_hint("Select All", mac_only("secondary-a")),
)
.separator()
.menu("Find…", Box::new(FindInTerminal))
.menu("Clear", Box::new(ClearScrollback));
let view = menu_view.read(cx);
let fork_label = view.agent().and_then(|a| a.fork_label());
let can_fork = fork_label.is_some()
&& view.remote_context().is_none()
&& view.agent_session().is_some_and(|s| s.session_id.is_some());
let menu = match fork_label {
Some(label) if can_fork => {
let focus = menu_focus.clone();
menu.separator()
.submenu(label, window, cx, move |submenu, _window, _cx| {
submenu
.action_context(focus.clone())
.menu("Split Right", Box::new(ForkAgentSessionRight))
.menu("Split Left", Box::new(ForkAgentSessionLeft))
.menu("Split Down", Box::new(ForkAgentSessionDown))
.menu("Split Up", Box::new(ForkAgentSessionUp))
})
}
Some(label) => menu
.separator()
.item(PopupMenuItem::new(label).disabled(true)),
None => menu,
};
menu.separator()
.menu("Split Right", Box::new(SplitRight))
.menu("Split Down", Box::new(SplitDown))
.menu("Maximize Pane", Box::new(ToggleMaximizePane))
.separator()
.menu("New Tab", Box::new(NewTab))
.menu("Close Pane", Box::new(CloseActiveTab))
})
}
}
fn menu_row_with_hint(
label: &'static str,
key: Option<&'static str>,
) -> impl Fn(&mut Window, &mut App) -> gpui::AnyElement {
move |_window, _cx| {
let hint = key.map(|k| {
Kbd::new(gpui::Keystroke::parse(k).expect("valid static keystroke"))
.p_0()
.flex_nowrap()
.border_0()
.bg(gpui::transparent_white())
});
h_flex()
.w_full()
.gap_3()
.items_center()
.justify_between()
.child(label)
.children(hint)
.into_any_element()
}
}
#[cfg(target_os = "macos")]
fn mac_only(key: &'static str) -> Option<&'static str> {
Some(key)
}
#[cfg(not(target_os = "macos"))]
fn mac_only(_key: &'static str) -> Option<&'static str> {
None
}
fn word_start_of(line: &str, cursor: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
let mut start = cursor.min(chars.len());
while start > 0 && !chars[start - 1].is_whitespace() {
start -= 1;
}
start
}
fn display_width(c: char) -> usize {
let u = c as u32;
let wide = matches!(u,
0x1100..=0x115F
| 0x2329 | 0x232A
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE10..=0xFE19 | 0xFE30..=0xFE6F
| 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6
| 0x1F300..=0x1FAFF
| 0x20000..=0x3FFFD
);
if wide { 2 } else { 1 }
}
#[derive(Debug, PartialEq)]
enum WheelRoute {
Report { base: u8 },
Arrows { seq: &'static [u8] },
Scrollback,
}
fn wheel_route(mode: TermMode, shift: bool, up: bool) -> WheelRoute {
if !shift && mode.intersects(TermMode::MOUSE_MODE) {
return WheelRoute::Report {
base: if up { 64 } else { 65 },
};
}
if !shift && mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
let seq: &'static [u8] = match (up, mode.contains(TermMode::APP_CURSOR)) {
(true, true) => b"\x1bOA",
(true, false) => b"\x1b[A",
(false, true) => b"\x1bOB",
(false, false) => b"\x1b[B",
};
return WheelRoute::Arrows { seq };
}
WheelRoute::Scrollback
}
#[derive(Debug, PartialEq)]
enum SelectEndCopy {
None,
Grid,
Editor,
}
fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy {
match (enabled, grid, editor) {
(false, ..) => SelectEndCopy::None,
(true, true, _) => SelectEndCopy::Grid,
(true, false, true) => SelectEndCopy::Editor,
(true, false, false) => SelectEndCopy::None,
}
}
fn open_file_path(path: &std::path::Path) {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(path).spawn() {
log::warn!("failed to open {}: {e}", path.display());
}
}
fn run_file_command(
template: &str,
path: &std::path::Path,
line: Option<u32>,
column: Option<u32>,
) {
let argv = expand_file_command_template(template, path, line, column);
let Some((program, args)) = argv.split_first() else {
log::warn!("link_file_command is empty; ignoring file link");
return;
};
if let Err(e) = std::process::Command::new(program).args(args).spawn() {
log::warn!("failed to run link_file_command {template:?}: {e}");
}
}
fn expand_file_command_template(
template: &str,
path: &std::path::Path,
line: Option<u32>,
column: Option<u32>,
) -> Vec<String> {
let path = path.to_string_lossy();
template
.split_whitespace()
.filter_map(|token| expand_file_command_token(token, &path, line, column))
.collect()
}
fn expand_file_command_token(
token: &str,
path: &str,
line: Option<u32>,
column: Option<u32>,
) -> Option<String> {
let mut out = String::with_capacity(token.len());
let mut rest = token;
while let Some(open) = rest.find('{') {
let Some(close_rel) = rest[open..].find('}') else {
break;
};
let close = open + close_rel;
out.push_str(&rest[..open]);
let value = match &rest[open + 1..close] {
"path" => Some(path.to_string()),
"line" => line.map(|l| l.to_string()),
"column" => column.map(|c| c.to_string()),
other => Some(format!("{{{other}}}")),
};
out.push_str(&value?);
rest = &rest[close + 1..];
}
out.push_str(rest);
Some(out)
}
fn encode_mouse(
sgr: bool,
base: u8,
mods: &Modifiers,
col: usize,
row: usize,
pressed: bool,
) -> Option<Vec<u8>> {
let mut mod_bits = 0u8;
if mods.shift {
mod_bits += 4;
}
if mods.alt {
mod_bits += 8;
}
if mods.control {
mod_bits += 16;
}
if sgr {
let c = if pressed { 'M' } else { 'm' };
let msg = format!("\x1b[<{};{};{}{}", base + mod_bits, col + 1, row + 1, c);
Some(msg.into_bytes())
} else {
if col >= 223 || row >= 223 {
return None;
}
let code = if pressed {
base + mod_bits
} else {
3 + mod_bits
};
Some(vec![
0x1b,
b'[',
b'M',
32 + code,
(32 + 1 + col) as u8,
(32 + 1 + row) as u8,
])
}
}
fn focus_report_bytes(mode: TermMode, focused: bool) -> Option<&'static [u8]> {
if !mode.contains(TermMode::FOCUS_IN_OUT) {
return None;
}
Some(if focused { b"\x1b[I" } else { b"\x1b[O" })
}
fn completion_row_icon(
raw: Option<&str>,
kind: CandidateKind,
color: gpui::Hsla,
) -> gpui::AnyElement {
let slot = |child: gpui::AnyElement| {
div()
.w(px(16.))
.flex()
.justify_center()
.items_center()
.child(child)
.into_any_element()
};
if let Some(raw) = raw {
if let Some(emoji) = fig_icon_emoji(raw) {
return slot(
div()
.text_size(px(13.))
.child(emoji.to_string())
.into_any_element(),
);
}
if let Some(name) = fig_icon_glyph(raw) {
return slot(
Icon::new(name)
.size(px(15.))
.text_color(color)
.into_any_element(),
);
}
}
let name = match kind {
CandidateKind::Command | CandidateKind::Value => IconName::SquareTerminal,
CandidateKind::Flag => IconName::Dash,
CandidateKind::Dir => IconName::Folder,
CandidateKind::File => IconName::File,
};
slot(
Icon::new(name)
.size(px(15.))
.text_color(color)
.into_any_element(),
)
}
fn fig_icon_emoji(raw: &str) -> Option<&str> {
if raw.is_empty() {
None
} else if !raw.starts_with("fig://") {
Some(raw)
} else if raw.starts_with("fig://template") {
fig_query_param(raw, "badge")
} else {
None
}
}
fn fig_icon_glyph(raw: &str) -> Option<IconName> {
let ty = raw
.strip_prefix("fig://icon")
.and_then(|r| fig_query_param(r, "type"))?;
match ty {
"folder" => Some(IconName::Folder),
"file" => Some(IconName::File),
"git" => Some(IconName::Github),
"asterisk" => Some(IconName::Asterisk),
_ => None,
}
}
fn fig_query_param<'a>(raw: &'a str, key: &str) -> Option<&'a str> {
raw.split_once('?')?.1.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == key).then_some(v)
})
}
fn menu_layout(
total_rows: usize,
srow: usize,
count: usize,
sel: usize,
max_rows: usize,
) -> (bool, usize, usize) {
let want = count.min(max_rows);
let below = total_rows.saturating_sub(srow + 1);
let above = srow;
let footers = if count > want { 2 } else { 0 };
let need = want + footers;
let (place_above, visible) = if below >= need {
(false, want)
} else if above >= need {
(true, want)
} else {
let squeeze = |room: usize| room.saturating_sub(2).max(1);
if above > below {
(true, squeeze(above))
} else {
(false, squeeze(below))
}
};
let visible = visible.min(count);
let first = sel
.saturating_sub(visible.saturating_sub(1))
.min(count.saturating_sub(visible));
(place_above, visible, first)
}
fn input_char_positions(
chars: &[char],
scol: usize,
cols: usize,
) -> (Vec<(usize, usize, usize)>, usize, usize) {
let mut positions: Vec<(usize, usize, usize)> = Vec::with_capacity(chars.len());
let mut r = 0usize;
let mut c = scol;
for &ch in chars {
if ch == '\n' {
positions.push((r, c, 0));
r += 1;
c = 0;
continue;
}
let w = display_width(ch).max(1);
if c + w > cols {
r += 1;
c = 0;
}
positions.push((r, c, w));
c += w;
}
(positions, r, c)
}
fn input_overlay_rows(
chars: &[char],
cursor: usize,
marked: &str,
scol: usize,
cols: usize,
) -> (usize, usize) {
let mut merged: Vec<char> = Vec::with_capacity(chars.len() + marked.len());
let cursor = cursor.min(chars.len());
merged.extend_from_slice(&chars[..cursor]);
merged.extend(marked.chars());
merged.extend_from_slice(&chars[cursor..]);
let (positions, r, c) = input_char_positions(&merged, scol, cols);
let end_row = if cursor >= chars.len() && marked.is_empty() && c >= cols {
r + 1
} else {
r
};
let caret_vrow = positions.get(cursor).map_or(end_row, |&(pr, _, _)| pr);
(end_row + 1, caret_vrow)
}
fn input_overflow_shift(crow: usize, caret_vrow: usize, visual_rows: usize, rows: usize) -> usize {
(crow + visual_rows)
.saturating_sub(rows)
.min(crow + caret_vrow)
}
fn wrapped_click_index(
chars: &[char],
scol: usize,
cols: usize,
col: usize,
target: usize,
clamp: bool,
) -> Option<usize> {
let len = chars.len();
let (positions, r, c) = input_char_positions(chars, scol, cols);
let end_row = if c >= cols { r + 1 } else { r };
if target > end_row {
return clamp.then_some(len);
}
for (i, &(pr, pc, pw)) in positions.iter().enumerate() {
if pr == target && col >= pc && col < pc + pw {
return Some(i);
}
}
if let Some(fi) = positions.iter().position(|&(pr, _, _)| pr == target) {
if col < positions[fi].1 {
return Some(fi);
}
}
if let Some(last) = positions.iter().rposition(|&(pr, _, _)| pr == target) {
if chars[last] == '\n' {
return Some(last);
}
}
match positions.iter().position(|&(pr, _, _)| pr > target) {
Some(ni) => Some(ni),
None => Some(len),
}
}
fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32, f32) {
let pos = (offset as f32 + frac + delta).clamp(0., max as f32);
let new_offset = pos.floor();
(new_offset as i32 - offset as i32, pos - new_offset)
}
fn drag_scroll_step(overshoot: f32) -> i32 {
let lines = overshoot.abs().ceil().clamp(1., 8.) as i32;
if overshoot < 0. { -lines } else { lines }
}
#[cfg(test)]
mod tests {
use super::{
LoopbackPlan, SelectEndCopy, WheelRoute, clipboard_paste_text, cwd_is_on_host,
display_width, loopback_plan,
};
use super::{
drag_scroll_step, encode_mouse, expand_file_command_template, fallback_chain,
fig_icon_emoji, fig_icon_glyph, focus_report_bytes, input_overflow_shift,
input_overlay_rows, menu_layout, paste_bytes, select_end_copy, shell_escape_path,
smooth_scroll_step, submit_bytes, trim_trailing_spaces, wheel_route, wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
use gpui_component::IconName;
use std::path::{Path, PathBuf};
use crate::core::session::{RemoteTarget, WorkspaceId};
use crate::daemon::protocol::RemoteKind;
use crate::terminal::PaneWorkspace;
fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace {
PaneWorkspace {
workspace: WorkspaceId::new(),
target,
spec: with_spec.then(|| {
Box::new(
serde_json::from_str(
r#"{"host":"dev.box","port":22,"user":"me","auth_mode":"auto"}"#,
)
.unwrap(),
)
}),
}
}
#[test]
fn local_pane_opens_localhost_directly() {
assert_eq!(loopback_plan(true, None, None, 1), LoopbackPlan::Direct);
}
#[test]
fn ssh_pane_forwards_on_the_pane() {
assert_eq!(
loopback_plan(true, None, Some(RemoteKind::NativeSsh), 7),
LoopbackPlan::ForwardOnPane(7)
);
assert_eq!(
loopback_plan(true, None, Some(RemoteKind::Wsl), 7),
LoopbackPlan::Direct
);
}
#[test]
fn remote_workspace_pane_forwards_on_the_workspace() {
let w = ws(RemoteTarget::direct("me", "dev.box", 22), true);
assert_eq!(
loopback_plan(true, Some(&w), None, 7),
LoopbackPlan::ForwardOnWorkspace(Box::new(w.clone())),
"no RemoteContext, but still forwarded"
);
assert_eq!(
loopback_plan(true, Some(&w), Some(RemoteKind::NativeSsh), 7),
LoopbackPlan::ForwardOnWorkspace(Box::new(w))
);
}
#[test]
fn wsl_workspace_needs_no_forward() {
let w = ws(
RemoteTarget::Wsl {
distro: "Ubuntu".into(),
},
false,
);
assert_eq!(
loopback_plan(true, Some(&w), None, 7),
LoopbackPlan::NoForwardNeeded
);
}
#[test]
fn workspace_without_a_spec_does_not_forward() {
let w = ws(RemoteTarget::direct("me", "dev.box", 22), false);
assert_eq!(loopback_plan(true, Some(&w), None, 7), LoopbackPlan::Direct);
}
#[test]
fn the_off_switch_disables_every_route() {
let w = ws(RemoteTarget::direct("me", "dev.box", 22), true);
assert_eq!(
loopback_plan(false, Some(&w), None, 7),
LoopbackPlan::Direct
);
assert_eq!(
loopback_plan(false, None, Some(RemoteKind::NativeSsh), 7),
LoopbackPlan::Direct
);
}
#[test]
fn file_command_template_substitutes_path_line_and_column() {
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
Some(42),
Some(7),
);
assert_eq!(
argv,
vec!["herdr", "edit", "/tmp/foo.rs", "--line=42", "--column=7",]
);
}
#[test]
fn file_command_template_drops_tokens_for_absent_values() {
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
None,
None,
);
assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs"]);
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
Some(42),
None,
);
assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs", "--line=42"]);
}
#[test]
fn file_command_template_keeps_path_only_token_and_unknown_placeholder() {
let argv = expand_file_command_template(
"code --goto {path}:{line} {other}",
Path::new("/tmp/foo.rs"),
None,
None,
);
assert_eq!(argv, vec!["code", "--goto", "{other}"]);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn clipboard_image_transcodes_bmp_to_png_and_passes_png_through() {
use gpui::{Image, ImageFormat};
let pixel = image::RgbaImage::from_pixel(1, 1, image::Rgba([1, 2, 3, 255]));
let mut bmp = Vec::new();
image::DynamicImage::ImageRgba8(pixel)
.write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp)
.unwrap();
let path = super::write_clipboard_image(&Image::from_bytes(ImageFormat::Bmp, bmp)).unwrap();
assert_eq!(path.extension().unwrap(), "png");
assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
let png = std::fs::read(&path).unwrap();
let out = super::write_clipboard_image(&Image::from_bytes(ImageFormat::Png, png.clone()))
.unwrap();
assert_eq!(out.extension().unwrap(), "png");
assert_eq!(std::fs::read(&out).unwrap(), png);
}
#[test]
fn fallback_chain_pins_bundled_hack_last() {
let configured = vec!["Menlo".to_string(), "Apple Color Emoji".to_string()];
let chain = fallback_chain("JetBrains Mono", &configured);
assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]);
assert_eq!(chain.last().unwrap(), "Hack");
let chain = fallback_chain("Hack", &configured);
assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]);
assert!(!chain.iter().any(|f| f == "Hack"));
let with_hack = vec!["Hack".to_string(), "Menlo".to_string()];
let chain = fallback_chain("SF Mono", &with_hack);
assert_eq!(chain[..2], ["Hack", "Menlo"]);
assert_eq!(
fallback_chain("Hack Nerd Font", &[]).last().unwrap(),
"Hack",
"a Hack-prefixed family name must not suppress the bundled anchor"
);
}
#[test]
fn fallback_chain_appends_platform_stock_faces() {
let stock = crate::core::config::platform_last_resort_fallbacks();
assert!(!stock.is_empty(), "every platform needs a CJK last resort");
let legacy = vec![
"Menlo".to_string(),
"Hasklug Nerd Font Mono".to_string(),
"Maple Mono NF CN".to_string(),
"Apple Color Emoji".to_string(),
];
let chain = fallback_chain("Hack", &legacy);
for name in stock {
assert!(
chain.iter().any(|f| f == name),
"{name} missing from repaired chain {chain:?}"
);
}
assert_eq!(chain[..legacy.len()], legacy[..]);
let explicit = vec![stock[0].to_string()];
let chain = fallback_chain("Hack", &explicit);
assert_eq!(
chain.iter().filter(|f| *f == stock[0]).count(),
1,
"stock face duplicated in {chain:?}"
);
assert!(!fallback_chain(stock[0], &[]).iter().any(|f| f == stock[0]));
}
#[test]
fn wheel_routes_by_negotiated_mode_with_reporting_first() {
let mouse = TermMode::MOUSE_REPORT_CLICK;
assert_eq!(
wheel_route(mouse, false, true),
WheelRoute::Report { base: 64 }
);
assert_eq!(
wheel_route(mouse, false, false),
WheelRoute::Report { base: 65 }
);
let alt = TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL;
assert_eq!(
wheel_route(alt, false, true),
WheelRoute::Arrows { seq: b"\x1b[A" }
);
assert_eq!(
wheel_route(alt, false, false),
WheelRoute::Arrows { seq: b"\x1b[B" }
);
assert_eq!(
wheel_route(alt | TermMode::APP_CURSOR, false, true),
WheelRoute::Arrows { seq: b"\x1bOA" }
);
assert_eq!(
wheel_route(alt | TermMode::APP_CURSOR, false, false),
WheelRoute::Arrows { seq: b"\x1bOB" }
);
assert_eq!(
wheel_route(mouse | alt, false, true),
WheelRoute::Report { base: 64 }
);
assert_eq!(
wheel_route(TermMode::empty(), false, true),
WheelRoute::Scrollback
);
}
#[test]
fn wheel_ignores_alternate_scroll_outside_the_alt_screen() {
assert_eq!(
wheel_route(TermMode::ALTERNATE_SCROLL, false, true),
WheelRoute::Scrollback
);
}
#[test]
fn shift_wheel_always_scrolls_the_local_scrollback() {
let everything = TermMode::MOUSE_MOTION
| TermMode::ALT_SCREEN
| TermMode::ALTERNATE_SCROLL
| TermMode::APP_CURSOR;
assert_eq!(wheel_route(everything, true, true), WheelRoute::Scrollback);
assert_eq!(wheel_route(everything, true, false), WheelRoute::Scrollback);
}
#[test]
fn copy_on_select_copies_the_buffer_the_gesture_touched() {
assert_eq!(select_end_copy(false, true, false), SelectEndCopy::None);
assert_eq!(select_end_copy(false, false, true), SelectEndCopy::None);
assert_eq!(select_end_copy(true, true, false), SelectEndCopy::Grid);
assert_eq!(select_end_copy(true, false, true), SelectEndCopy::Editor);
assert_eq!(select_end_copy(true, false, false), SelectEndCopy::None);
assert_eq!(select_end_copy(true, true, true), SelectEndCopy::Grid);
}
#[test]
fn sgr_mouse_reports_one_based_decimal_with_modifier_bits() {
let plain = Modifiers::default();
assert_eq!(
encode_mouse(true, 0, &plain, 4, 8, true).unwrap(),
b"\x1b[<0;5;9M".to_vec()
);
assert_eq!(
encode_mouse(true, 2, &plain, 4, 8, false).unwrap(),
b"\x1b[<2;5;9m".to_vec()
);
let all = Modifiers {
shift: true,
alt: true,
control: true,
..Modifiers::default()
};
assert_eq!(
encode_mouse(true, 0, &all, 0, 0, true).unwrap(),
b"\x1b[<28;1;1M".to_vec()
);
assert_eq!(
encode_mouse(true, 64, &plain, 10, 3, true).unwrap(),
b"\x1b[<64;11;4M".to_vec()
);
assert_eq!(
encode_mouse(true, 35, &plain, 1, 1, true).unwrap(),
b"\x1b[<35;2;2M".to_vec()
);
}
#[test]
fn sgr_mouse_has_no_coordinate_cap() {
let plain = Modifiers::default();
assert_eq!(
encode_mouse(true, 0, &plain, 500, 300, true).unwrap(),
b"\x1b[<0;501;301M".to_vec()
);
}
#[test]
fn x10_mouse_packs_bytes_and_drops_button_on_release() {
let plain = Modifiers::default();
assert_eq!(
encode_mouse(false, 0, &plain, 4, 8, true).unwrap(),
vec![0x1b, b'[', b'M', 32, 32 + 1 + 4, 32 + 1 + 8]
);
assert_eq!(
encode_mouse(false, 2, &plain, 4, 8, false).unwrap(),
vec![0x1b, b'[', b'M', 32 + 3, 32 + 1 + 4, 32 + 1 + 8]
);
let ctrl = Modifiers {
control: true,
..Modifiers::default()
};
assert_eq!(
encode_mouse(false, 1, &ctrl, 0, 0, true).unwrap(),
vec![0x1b, b'[', b'M', 32 + 1 + 16, 33, 33]
);
}
#[test]
fn x10_mouse_drops_out_of_range_coordinates_whole() {
let plain = Modifiers::default();
assert!(encode_mouse(false, 0, &plain, 223, 0, true).is_none());
assert!(encode_mouse(false, 0, &plain, 0, 223, true).is_none());
let last = encode_mouse(false, 0, &plain, 222, 222, true).unwrap();
assert_eq!(&last[4..], &[255, 255]);
}
#[test]
fn fig_icon_emoji_takes_bare_emoji_and_template_badge_only() {
assert_eq!(fig_icon_emoji("⚙️"), Some("⚙️"));
assert_eq!(
fig_icon_emoji("fig://template?color=2ecc71&badge=🔥"),
Some("🔥")
);
assert_eq!(fig_icon_emoji("fig://icon?type=git"), None);
assert_eq!(fig_icon_emoji("fig://template?color=2ecc71"), None);
assert_eq!(fig_icon_emoji(""), None);
}
#[test]
fn fig_icon_glyph_maps_known_types_and_falls_back_otherwise() {
assert!(matches!(
fig_icon_glyph("fig://icon?type=folder"),
Some(IconName::Folder)
));
assert!(matches!(
fig_icon_glyph("fig://icon?type=file"),
Some(IconName::File)
));
assert!(matches!(
fig_icon_glyph("fig://icon?type=git"),
Some(IconName::Github)
));
assert!(fig_icon_glyph("fig://icon?type=docker").is_none());
assert!(fig_icon_glyph("⚙️").is_none());
}
#[test]
fn focus_reports_only_when_the_app_opted_in() {
assert_eq!(focus_report_bytes(TermMode::empty(), true), None);
assert_eq!(focus_report_bytes(TermMode::empty(), false), None);
let mode = TermMode::FOCUS_IN_OUT;
assert_eq!(focus_report_bytes(mode, true), Some(b"\x1b[I".as_slice()));
assert_eq!(focus_report_bytes(mode, false), Some(b"\x1b[O".as_slice()));
assert_eq!(focus_report_bytes(TermMode::MOUSE_MOTION, true), None);
}
#[test]
fn smooth_scroll_step_accumulates_and_clamps() {
assert_eq!(smooth_scroll_step(0, 0.0, 0.4, 100), (0, 0.4));
let (jump, frac) = smooth_scroll_step(0, 0.4, 0.8, 100);
assert_eq!(jump, 1);
assert!((frac - 0.2).abs() < 1e-4);
let (jump, frac) = smooth_scroll_step(5, 0.2, -0.5, 100);
assert_eq!(jump, -1);
assert!((frac - 0.7).abs() < 1e-4);
assert_eq!(smooth_scroll_step(3, 0.5, -10.0, 100), (-3, 0.0));
assert_eq!(smooth_scroll_step(98, 0.0, 7.3, 100), (2, 0.0));
assert_eq!(smooth_scroll_step(0, 0.0, 2.5, 0), (0, 0.0));
}
#[test]
fn drag_scroll_step_scales_with_overshoot_and_caps() {
assert_eq!(drag_scroll_step(0.2), 1);
assert_eq!(drag_scroll_step(-0.2), -1);
assert_eq!(drag_scroll_step(3.5), 4);
assert_eq!(drag_scroll_step(-3.5), -4);
assert_eq!(drag_scroll_step(50.0), 8);
assert_eq!(drag_scroll_step(-50.0), -8);
}
#[test]
fn trim_trailing_spaces_strips_per_line_and_preserves_structure() {
assert_eq!(trim_trailing_spaces("a \nb\t\nc"), "a\nb\nc");
assert_eq!(trim_trailing_spaces("a \n"), "a\n");
assert_eq!(trim_trailing_spaces("a "), "a");
assert_eq!(trim_trailing_spaces(" a "), " a");
}
#[test]
fn paste_bytes_strips_esc_to_prevent_bracketed_paste_escape() {
assert_eq!(
paste_bytes("ls -la", true),
b"\x1b[200~ls -la\x1b[201~".to_vec()
);
let evil = "foo\x1b[201~\nrm -rf ~\n";
let out = paste_bytes(evil, true);
let end = b"\x1b[201~";
let markers = out.windows(end.len()).filter(|w| *w == end).count();
assert_eq!(markers, 1);
let inner = &out[b"\x1b[200~".len()..out.len() - end.len()];
assert!(!inner.contains(&0x1b));
assert_eq!(inner, b"foo[201~\nrm -rf ~\n");
assert_eq!(paste_bytes("a\x1b[201~b", false), b"a\x1b[201~b".to_vec());
}
#[test]
fn paste_bytes_normalizes_newlines_to_cr_without_bracketed_paste() {
assert_eq!(paste_bytes("a\nb\r\nc\n", false), b"a\rb\rc\r".to_vec());
assert_eq!(
paste_bytes("a\nb", true),
b"\x1b[200~a\nb\x1b[201~".to_vec()
);
}
#[test]
fn paste_bytes_folds_crlf_so_a_windows_clipboard_pastes_like_any_other() {
assert_eq!(
paste_bytes("a\r\nb\r\n", true),
b"\x1b[200~a\nb\n\x1b[201~".to_vec(),
"CRLF must reach the app as one line break, not two"
);
assert_eq!(
paste_bytes("a\r\nb", true),
paste_bytes("a\nb", true),
"a Windows clipboard must paste exactly like a Unix one"
);
}
#[test]
fn submit_bytes_sends_a_multi_line_command_as_one_bracketed_paste() {
assert_eq!(
submit_bytes("echo a\necho b\necho c", true),
b"\x1b[200~echo a\necho b\necho c\x1b[201~\r".to_vec()
);
let out = submit_bytes("a\nb\nc\nd", true);
assert_eq!(out.iter().filter(|&&b| b == b'\r').count(), 1);
assert_eq!(
submit_bytes("ls -la", true),
b"\x1b[200~ls -la\x1b[201~\r".to_vec()
);
}
#[test]
fn submit_bytes_falls_back_to_per_line_cr_without_bracketed_paste() {
assert_eq!(submit_bytes("a\nb", false), b"a\rb\r".to_vec());
assert_eq!(submit_bytes("a\r\nb", false), b"a\rb\r".to_vec());
}
#[test]
fn submit_bytes_normalizes_line_breaks_inside_the_paste() {
assert_eq!(
submit_bytes("a\r\nb", true),
b"\x1b[200~a\nb\x1b[201~\r".to_vec()
);
assert_eq!(
submit_bytes("a\rb", true),
b"\x1b[200~a\nb\x1b[201~\r".to_vec()
);
assert_eq!(submit_bytes("a\rb", false), b"a\rb\r".to_vec());
}
#[test]
fn submit_bytes_strips_esc_and_skips_markers_on_an_empty_line() {
let out = submit_bytes("foo\x1b[201~\nrm -rf ~", true);
let end = b"\x1b[201~";
assert_eq!(out.windows(end.len()).filter(|w| *w == end).count(), 1);
assert_eq!(out, b"\x1b[200~foo[201~\nrm -rf ~\x1b[201~\r".to_vec());
assert_eq!(submit_bytes("a\x1bb", false), b"ab\r".to_vec());
assert_eq!(submit_bytes("", true), b"\r".to_vec());
}
#[test]
fn shell_escape_path_escapes_spaces_and_metachars() {
assert_eq!(
shell_escape_path("/Users/me/notes.txt"),
"/Users/me/notes.txt"
);
assert_eq!(
shell_escape_path("/Users/me/My File (1).txt"),
"/Users/me/My\\ File\\ \\(1\\).txt"
);
assert_eq!(
shell_escape_path("/a/$HOME & more"),
"/a/\\$HOME\\ \\&\\ more"
);
assert_eq!(shell_escape_path(""), "''");
assert_eq!(shell_escape_path("a\nb"), "'a\nb'");
}
#[test]
fn clipboard_paste_text_escapes_and_space_joins_files() {
let item = ClipboardItem {
entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(
vec![
PathBuf::from("/Users/me/My File.txt"),
PathBuf::from("/tmp/b.log"),
]
.into(),
))],
};
assert_eq!(
clipboard_paste_text(&item).as_deref(),
Some("/Users/me/My\\ File.txt /tmp/b.log")
);
let text = ClipboardItem::new_string("echo hi".to_string());
assert_eq!(clipboard_paste_text(&text).as_deref(), Some("echo hi"));
}
#[test]
fn display_width_ascii_and_control_are_narrow() {
assert_eq!(display_width('a'), 1);
assert_eq!(display_width(' '), 1);
assert_eq!(display_width('~'), 1);
assert_eq!(display_width('\t'), 1);
}
#[test]
fn display_width_cjk_and_kana_are_wide() {
assert_eq!(display_width('你'), 2);
assert_eq!(display_width('한'), 2);
assert_eq!(display_width('あ'), 2);
assert_eq!(display_width(' '), 2);
}
#[test]
fn display_width_emoji_are_wide() {
assert_eq!(display_width('🚀'), 2);
assert_eq!(display_width('🎉'), 2);
}
#[test]
fn display_width_latin_accents_stay_narrow() {
assert_eq!(display_width('é'), 1);
assert_eq!(display_width('©'), 1);
assert_eq!(display_width('±'), 1);
}
fn click(text: &str, scol: usize, cols: usize, col: usize, row: usize) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
wrapped_click_index(&chars, scol, cols, col, row, false)
}
#[test]
fn wrapped_click_index_hits_chars_on_the_first_row() {
assert_eq!(click("git", 4, 80, 4, 0), Some(0));
assert_eq!(click("git", 4, 80, 6, 0), Some(2));
assert_eq!(click("git", 4, 80, 1, 0), Some(0));
assert_eq!(click("git", 4, 80, 40, 0), Some(3));
}
#[test]
fn wrapped_click_index_maps_wrapped_rows() {
assert_eq!(click("abcdef", 8, 10, 9, 0), Some(1));
assert_eq!(click("abcdef", 8, 10, 0, 1), Some(2));
assert_eq!(click("abcdef", 8, 10, 3, 1), Some(5));
assert_eq!(click("a你", 2, 4, 3, 0), Some(1));
assert_eq!(click("abcdef", 8, 10, 9, 1), Some(6));
}
#[test]
fn wrapped_click_index_respects_wide_chars() {
assert_eq!(click("你好", 2, 80, 2, 0), Some(0));
assert_eq!(click("你好", 2, 80, 3, 0), Some(0));
assert_eq!(click("你好", 2, 80, 4, 0), Some(1));
assert_eq!(click("你", 4, 5, 0, 1), Some(0));
assert_eq!(click("你", 4, 5, 1, 1), Some(0));
}
#[test]
fn wrapped_click_index_rows_past_the_input_need_clamp() {
let chars: Vec<char> = "ls".chars().collect();
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, false), None);
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, true), Some(2));
assert_eq!(wrapped_click_index(&[], 4, 80, 30, 0, false), Some(0));
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 1, false), None);
}
#[test]
fn wrapped_click_index_covers_the_wrapped_caret_slot() {
assert_eq!(click("abcdef", 4, 10, 0, 1), Some(6));
assert_eq!(click("abcdef", 4, 10, 7, 1), Some(6));
let chars: Vec<char> = "abcdef".chars().collect();
assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None);
}
#[test]
fn wrapped_click_index_treats_newlines_as_hard_breaks() {
assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0));
assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2));
assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3));
assert_eq!(click("a\nbc", 4, 80, 40, 0), Some(1));
assert_eq!(click("a\nbc", 4, 80, 40, 1), Some(4));
assert_eq!(click("a\n\nb", 4, 80, 3, 1), Some(2));
assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3));
}
#[test]
fn input_overlay_rows_counts_wraps_slot_marked_and_newlines() {
let rows = |text: &str, cursor: usize, marked: &str, scol: usize, cols: usize| {
let chars: Vec<char> = text.chars().collect();
input_overlay_rows(&chars, cursor, marked, scol, cols)
};
assert_eq!(rows("", 0, "", 3, 8), (1, 0));
assert_eq!(rows("aaaaaaaaaa", 10, "", 6, 8), (3, 2));
assert_eq!(rows("aaaaaaaaaa", 3, "", 6, 8), (2, 1));
assert_eq!(rows("ab\ncd", 5, "", 0, 8), (2, 1));
assert_eq!(rows("ab", 1, "漢", 6, 8), (2, 1));
}
#[test]
fn input_overflow_shift_keeps_the_tail_and_caret_visible() {
assert_eq!(input_overflow_shift(5, 2, 3, 22), 0);
assert_eq!(input_overflow_shift(20, 2, 3, 22), 1);
assert_eq!(input_overflow_shift(21, 29, 30, 22), 29);
assert_eq!(input_overflow_shift(21, 0, 30, 22), 21);
}
#[test]
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
assert_eq!(menu_layout(24, 3, 5, 0, 10), (false, 5, 0));
assert_eq!(menu_layout(24, 22, 5, 0, 10), (true, 5, 0));
assert_eq!(menu_layout(6, 4, 10, 0, 10), (true, 2, 0));
assert_eq!(menu_layout(6, 1, 10, 0, 10), (false, 2, 0));
let (_, visible, _) = menu_layout(1, 0, 8, 0, 10);
assert_eq!(visible, 1);
}
#[test]
fn menu_layout_budgets_the_overflow_footers() {
let (place_above, visible, first) = menu_layout(24, 13, 30, 17, 10);
assert!(
place_above,
"12 needed lines don't fit in the 10 rows below"
);
assert_eq!(visible, 10);
assert!((first..first + visible).contains(&17));
}
#[test]
fn menu_layout_caps_rows_and_windows_around_the_selection() {
let (_, visible, first) = menu_layout(40, 0, 30, 17, 10);
assert_eq!(visible, 10);
assert!((first..first + visible).contains(&17));
assert_eq!(first, 8);
let (_, visible, first) = menu_layout(40, 0, 30, 29, 10);
assert_eq!(first, 20);
assert_eq!(first + visible, 30);
assert_eq!(menu_layout(40, 0, 30, 3, 10).2, 0);
}
#[test]
fn only_a_matching_host_may_answer_for_a_panes_paths() {
assert!(cwd_is_on_host(false, true));
assert!(cwd_is_on_host(true, false));
assert!(!cwd_is_on_host(true, true));
assert!(!cwd_is_on_host(false, false));
}
#[test]
fn a_panes_host_is_its_workspaces_machine() {
use crate::core::session::{RemoteTarget, WorkspaceId};
use crate::ui::host_ops::HostId;
let target = RemoteTarget::Alias {
alias: "build-box".into(),
};
let ws = PaneWorkspace {
workspace: WorkspaceId::new(),
target: target.clone(),
spec: None,
};
let remote = ws.target.host_id();
assert_eq!(remote, target.host_id(), "the workspace's own machine");
assert!(!remote.is_local(), "a remote workspace is not this machine");
assert_eq!(
HostId::from_connection_key("ssh-alias:build-box"),
remote,
"the id the connection was opened under, or the registry lookup misses"
);
let sibling = PaneWorkspace {
workspace: WorkspaceId::new(),
target,
spec: None,
};
assert_eq!(sibling.target.host_id(), remote);
}
}
#[cfg(all(test, unix))]
pub(crate) fn quiet_test_pane(
pane_id: u64,
window: &mut Window,
cx: &mut gpui::App,
) -> (gpui::Entity<TerminalView>, std::os::unix::net::UnixStream) {
let (client_side, daemon_side) = std::os::unix::net::UnixStream::pair().unwrap();
let terminal = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24))
.expect("socketpair-backed terminal");
let view = cx.new(|cx| TerminalView::with_terminal(terminal, pane_id, window, cx));
(view, daemon_side)
}
#[cfg(all(test, unix))]
pub(crate) fn quiet_test_ssh_pane(
pane_id: u64,
window: &mut Window,
cx: &mut gpui::App,
) -> (gpui::Entity<TerminalView>, std::os::unix::net::UnixStream) {
let (view, stream) = quiet_test_pane(pane_id, window, cx);
view.update(cx, |view, _| {
view.ssh_spec = Some(Box::new(
serde_json::from_str(
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
)
.expect("a minimal NativeSshSpec decodes"),
));
});
(view, stream)
}
#[cfg(all(test, unix))]
mod gpui_tests {
use super::*;
use crate::daemon::protocol::{ClientMsg, DaemonMsg};
use gpui::TestAppContext;
use std::os::unix::net::UnixStream;
fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle<TerminalView>, UnixStream) {
cx.executor().allow_parking();
let (client_side, daemon_side) = UnixStream::pair().unwrap();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
});
let window = cx.add_window(|window, cx| {
let terminal = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24))
.expect("socketpair-backed terminal");
TerminalView::with_terminal(terminal, 1, window, cx)
});
(window, daemon_side)
}
fn prompt_ready(
window: &gpui::WindowHandle<TerminalView>,
cx: &mut TestAppContext,
daemon: &mut UnixStream,
) {
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(daemon)
.unwrap();
for _ in 0..200 {
if window
.update(cx, |view, _, _| view.terminal.at_prompt())
.unwrap()
{
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the prompt report never reached the view");
}
#[gpui::test]
fn a_reported_session_id_asks_the_window_to_save(cx: &mut TestAppContext) {
use crate::core::cli_agent::{AgentSessionState, AgentStatus};
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
let saves = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let view = window.update(cx, |_, _, cx| cx.entity()).unwrap();
{
let saves = saves.clone();
cx.update(|cx| {
cx.subscribe(&view, move |_, _: &AgentSessionChanged, _| {
saves.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
.detach();
});
}
DaemonMsg::AgentStatus(Some(AgentSessionState {
status: AgentStatus::Idle,
message: None,
session_id: Some("sid-abc".into()),
launch_argv: Some(vec!["claude".into()]),
rich: true,
cwd: None,
activity: 0,
}))
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
if window
.update(cx, |view, _, _| view.terminal.agent_session().is_some())
.unwrap()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| view.poll_agent_status(false, cx))
.unwrap();
cx.run_until_parked();
assert_eq!(
saves.load(std::sync::atomic::Ordering::SeqCst),
1,
"the id has to reach the layout on file"
);
window
.update(cx, |view, _, cx| view.poll_agent_status(false, cx))
.unwrap();
cx.run_until_parked();
assert_eq!(
saves.load(std::sync::atomic::Ordering::SeqCst),
1,
"an unchanged session must not re-save on every poll"
);
}
#[gpui::test]
fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.hover_link_at(0, 23, true, cx);
view.terminal.resize(TermSize::new(80, 8), 8, 17);
view.last_hover_cell = Some((0, 23));
assert!(
!view.refresh_link_hover(true, cx),
"a row outside the grid can't hold a link"
);
})
.unwrap();
}
#[gpui::test]
fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.set_grid_size(80, 24, px(8.), px(17.), 1.);
view.hover_link_at(0, 23, true, cx);
assert_eq!(view.last_hover_cell, Some((0, 23)));
view.hovered_link = Some(HoveredLink {
start: Point::new(Line(23), Column(0)),
end: Point::new(Line(23), Column(3)),
});
view.set_grid_size(80, 24, px(8.), px(17.), 1.);
assert_eq!(view.last_hover_cell, Some((0, 23)));
view.set_grid_size(80, 8, px(8.), px(17.), 1.);
assert!(view.last_hover_cell.is_none(), "the cell is stale");
assert!(view.hovered_link.is_none(), "so is the link it resolved");
})
.unwrap();
}
#[gpui::test]
fn title_events_drive_the_tab_title(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
assert_eq!(view.title, "tty7");
view.handle_event(AlacEvent::Title("vim — main.rs".into()), cx);
assert_eq!(view.title, "vim — main.rs");
view.handle_event(AlacEvent::ResetTitle, cx);
assert_eq!(view.title, "tty7");
})
.unwrap();
}
fn next_input(daemon: &mut UnixStream) -> Vec<u8> {
loop {
match ClientMsg::read(daemon).expect("client socket stays open") {
ClientMsg::Input(bytes) => return bytes,
_ => continue,
}
}
}
fn type_char(
view: &mut TerminalView,
ch: &str,
window: &mut Window,
cx: &mut Context<TerminalView>,
) {
if cfg!(target_os = "macos") {
let _ = window;
view.commit_text(ch, cx);
} else {
let ev = KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: gpui::Modifiers::default(),
key: ch.to_string(),
key_char: Some(ch.to_string()),
},
is_held: false,
prefer_character_input: false,
};
view.on_key_down(&ev, window, cx);
}
}
fn next_input_until_timeout(daemon: &mut UnixStream) -> Option<Vec<u8>> {
use std::io::ErrorKind;
daemon
.set_read_timeout(Some(std::time::Duration::from_millis(250)))
.unwrap();
loop {
match ClientMsg::read(daemon) {
Ok(ClientMsg::Input(bytes)) => return Some(bytes),
Ok(_) => continue,
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
return None;
}
Err(e) => panic!("client socket failed before Input: {e}"),
}
}
}
#[gpui::test]
fn ctrl_l_at_prompt_reaches_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let ctrl_l = gpui::Keystroke {
modifiers: gpui::Modifiers {
control: true,
..Default::default()
},
key: "l".to_string(),
key_char: None,
};
view.handle_editor_key(&ctrl_l, cx);
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x0c]));
}
#[gpui::test]
fn shell_vi_mode_prompt_bypasses_the_local_editor(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let ready = window
.update(cx, |view, _, _| {
view.terminal.shell_vi_mode() && view.terminal.zle_reading()
})
.unwrap();
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
assert!(
!view.input_active(),
"shell vi-mode lets the shell line editor own prompt input"
);
type_char(view, "a", window, cx);
assert_eq!(
view.cmd.text(),
"",
"vi-mode prompt input must not draw through the local overlay"
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"a".to_vec()),
"shell vi-mode prompt input must reach the shell directly"
);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let active = window.update(cx, |view, _, _| view.input_active()).unwrap();
if active {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("an emacs-mode prompt should re-enable tty7's local editor");
}
fn wait_for_input_active(window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext) {
for _ in 0..200 {
cx.run_until_parked();
let active = window.update(cx, |view, _, _| view.input_active()).unwrap();
if active {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the local editor never engaged at the prompt");
}
#[gpui::test]
fn tab_with_no_candidates_hands_the_line_to_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
window
.update(cx, |view, window, cx| {
for ch in ["z", "z", "q", "q", "x"] {
type_char(view, ch, window, cx);
}
assert_eq!(view.cmd.text(), "zzqqx");
view.complete_tab(true, cx);
assert_eq!(view.cmd.text(), "", "the line moved to the shell");
assert!(
!view.input_active(),
"the shell owns the prompt after the handoff"
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"zzqqx".to_vec()),
"the edited line ships ahead of the Tab"
);
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"\t".to_vec()),
"the Tab reaches the PTY instead of being swallowed"
);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let applied = window
.update(cx, |view, _, _| view.terminal.prompt_seq() >= 2)
.unwrap();
if applied {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, _| {
assert!(
!view.input_active(),
"a same-prompt redraw must not re-engage the editor"
);
})
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: false,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
}
#[gpui::test]
fn a_late_remote_listing_leaves_a_line_the_editor_no_longer_owns_alone(
cx: &mut TestAppContext,
) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("ls /nope/");
view.editor_handoff = Some(view.terminal.prompt_cycle());
assert!(!view.input_active(), "the shell owns this prompt already");
let req =
super::completion::remote_path_request("ls /nope/", 9, "/home/u").unwrap();
view.remote_path_results(req, "ls /nope/", 9, Vec::new(), true, cx);
assert_eq!(
view.cmd.text(),
"ls /nope/",
"an empty listing must not hand off a line the editor no longer drives"
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"not one byte reached the wire"
);
}
#[gpui::test]
fn tab_completion_off_sends_every_tab_to_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.tab_completion = false;
cx.set_global(cfg);
});
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
window
.update(cx, |view, window, cx| {
for ch in ["c", "d", " "] {
type_char(view, ch, window, cx);
}
view.complete_tab(true, cx);
assert!(view.completion.is_none(), "no tty7 menu while opted out");
assert_eq!(view.cmd.text(), "");
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(b"cd ".to_vec()));
assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\t".to_vec()));
}
#[gpui::test]
fn shell_vi_mode_prompt_input_is_not_typeahead(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let ready = window
.update(cx, |view, _, _| {
!view.input_active()
&& view.terminal.shell_vi_mode()
&& view.terminal.zle_reading()
})
.unwrap();
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
type_char(view, "i", window, cx);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"i".to_vec()),
"vi prompt input is normal shell input, not deferred gap typeahead"
);
DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let active = window.update(cx, |view, _, _| view.input_active()).unwrap();
if active {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, _| assert!(view.input_active()))
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"leaving shell vi-mode must not flush a stale typeahead wipe"
);
}
#[gpui::test]
fn shell_vi_mode_prompt_releases_gap_hold_without_stale_typeahead(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: false,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let gap = window
.update(cx, |view, _, _| {
view.terminal.shell_active() && !view.terminal.at_prompt()
})
.unwrap();
if gap {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| view.commit_text("ls", cx))
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let ready = window
.update(cx, |view, _, _| {
view.terminal.shell_vi_mode() && view.terminal.zle_reading()
})
.unwrap();
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
cx.executor().advance_clock(HOLD_WINDOW * 2);
cx.run_until_parked();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"ls".to_vec()),
"gap text typed before a vi prompt must reach the shell"
);
DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
cx.run_until_parked();
let active = window.update(cx, |view, _, _| view.input_active()).unwrap();
if active {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, _| {
assert!(view.input_active());
assert_eq!(
view.cmd.text(),
"",
"gap text consumed at the vi prompt must not resurrect in the editor"
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"no stale ^U wipe once the vi prompt consumed the gap text"
);
}
fn key(spec: &str) -> gpui::Keystroke {
gpui::Keystroke::parse(spec).expect("valid keystroke spec")
}
#[test]
fn shim_detection_names_known_wrappers_only() {
assert_eq!(known_pty_shim("zsh (kiro-cli-term)"), Some("kiro-cli-term"));
assert_eq!(known_pty_shim("figterm"), Some("figterm"));
assert_eq!(known_pty_shim("qterm"), Some("qterm"));
assert_eq!(known_pty_shim("ssh"), None);
assert_eq!(known_pty_shim("wezterm"), None);
assert_eq!(known_pty_shim(""), None);
assert!(integration_notice_message(Some("kiro-cli-term")).contains("kiro-cli-term"));
assert!(!integration_notice_message(None).contains("intercepting"));
}
#[gpui::test]
fn ctrl_r_without_integration_raises_the_notice_once(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, _daemon) = harness(cx);
window
.update(cx, |view, window, cx| {
let ctrl_r = KeyDownEvent {
keystroke: key("ctrl-r"),
is_held: false,
prefer_character_input: false,
};
view.on_key_down(&ctrl_r, window, cx);
assert!(
view.integration_notice.is_none(),
"the grace window stays silent"
);
view.created_at = std::time::Instant::now() - INTEGRATION_GRACE * 2;
view.on_key_down(&ctrl_r, window, cx);
assert!(
view.integration_notice.is_some(),
"Ctrl+R raises the notice"
);
cx.notify();
})
.unwrap();
cx.run_until_parked();
window
.update(cx, |view, window, cx| {
assert!(
view.integration_notice.is_some(),
"the notice survives a real render pass"
);
let ctrl_r = KeyDownEvent {
keystroke: key("ctrl-r"),
is_held: false,
prefer_character_input: false,
};
view.on_key_down(&ctrl_r, window, cx);
assert!(
view.integration_notice.is_none(),
"a keystroke dismisses the notice"
);
view.on_key_down(&ctrl_r, window, cx);
assert!(
view.integration_notice.is_none(),
"the notice is one-shot per pane"
);
})
.unwrap();
}
#[gpui::test]
fn insert_newline_action_extends_the_line_and_enter_submits_it(cx: &mut TestAppContext) {
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
crate::core::config::set_config_dir(dir);
let (window, mut daemon) = harness(cx);
prompt_ready(&window, cx, &mut daemon);
window
.update(cx, |view, _, cx| {
assert!(view.input_active(), "the editor owns an idle prompt");
view.commit_text("echo a", cx);
view.insert_newline_action(cx);
view.commit_text("echo b", cx);
assert_eq!(view.cmd.text(), "echo a\necho b");
view.handle_editor_key(&key("enter"), cx);
assert!(view.cmd.is_empty(), "Enter submits the whole buffer");
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"echo a\recho b\r".to_vec()),
"the multi-line command reaches the PTY in one submit"
);
}
#[gpui::test]
fn insert_newline_action_closes_the_completion_menu_but_enter_still_accepts(
cx: &mut TestAppContext,
) {
let (window, mut daemon) = harness(cx);
prompt_ready(&window, cx, &mut daemon);
let candidate = |text: &str| completion::Candidate {
text: text.to_string(),
kind: CandidateKind::Command,
start: 4,
end: 4,
description: None,
icon: None,
};
window
.update(cx, |view, _, cx| {
view.cmd.set_with_cursor("git ", 4);
view.open_completion(CompletionSession::new(
4,
String::new(),
vec![candidate("status")],
));
view.insert_newline_action(cx);
assert!(
view.completion.is_none(),
"the newline ends the completed word, so the menu closes"
);
assert_eq!(view.cmd.text(), "git \n");
view.cmd.set_with_cursor("git ", 4);
view.open_completion(CompletionSession::new(
4,
String::new(),
vec![candidate("status")],
));
view.handle_editor_key(&key("enter"), cx);
assert_eq!(view.cmd.text(), "git status ");
})
.unwrap();
}
#[gpui::test]
fn insert_newline_action_declines_when_the_editor_is_not_live(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("keep me");
view.terminal.exited = true;
assert!(!view.input_active());
view.insert_newline_action(cx);
assert_eq!(view.cmd.text(), "keep me", "no newline inserted");
})
.unwrap();
}
#[gpui::test]
fn the_keymap_routes_both_newline_chords_to_the_action(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
prompt_ready(&window, cx, &mut daemon);
window
.update(cx, |view, window, cx| {
window.activate_window();
view.focus_handle.focus(window, cx);
view.commit_text("echo a", cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("shift-enter");
vcx.simulate_keystrokes("alt-enter");
window
.update(cx, |view, _, _| {
assert_eq!(
view.cmd.text(),
"echo a\n\n",
"both chords dispatched InsertNewline instead of submitting"
);
})
.unwrap();
cx.update(|cx| crate::ui::keymap::rebind(cx));
vcx.simulate_keystrokes("shift-enter");
window
.update(cx, |view, _, _| {
assert_eq!(
view.cmd.text(),
"echo a\n\n\n",
"the chord survives a rebind"
);
})
.unwrap();
}
#[gpui::test]
fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "cargo build", "git commit -m x"]
.into_iter()
.map(String::from)
.collect();
view.history_frecency = vec![0.0; view.history.len()];
view.handle_editor_key(&key("ctrl-r"), cx);
assert!(view.reverse_search.is_some(), "Ctrl+R opens the search");
view.commit_text("gst", cx);
assert_eq!(
view.reverse_search
.as_ref()
.and_then(|rs| rs.selected_line(&view.history)),
Some("git status")
);
view.handle_editor_key(&key("enter"), cx);
assert!(view.reverse_search.is_none(), "Enter closes the search");
assert_eq!(view.cmd.text(), "git status");
})
.unwrap();
}
#[gpui::test]
fn ctrl_r_steps_matches_and_cmd_enter_runs(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "cargo build", "git commit -m x"]
.into_iter()
.map(String::from)
.collect();
view.history_frecency = vec![0.0; view.history.len()];
view.handle_editor_key(&key("ctrl-r"), cx);
view.commit_text("git", cx);
assert_eq!(
view.reverse_search
.as_ref()
.and_then(|rs| rs.selected_line(&view.history)),
Some("git commit -m x")
);
view.handle_editor_key(&key("ctrl-r"), cx);
assert_eq!(
view.reverse_search
.as_ref()
.and_then(|rs| rs.selected_line(&view.history)),
Some("git status")
);
view.handle_editor_key(&key("cmd-enter"), cx);
assert!(view.reverse_search.is_none());
assert!(view.cmd.is_empty(), "submit clears the editor");
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(b"git status\r".to_vec()),
"Cmd+Enter ships the selected line to the PTY"
);
}
#[gpui::test]
fn ctrl_j_and_ctrl_m_submit_the_line_like_enter(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
for (chord, line) in [("ctrl-j", "echo j"), ("ctrl-m", "echo m")] {
window
.update(cx, |view, _, cx| {
view.cmd.set(line);
view.handle_editor_key(&key(chord), cx);
assert!(view.cmd.is_empty(), "{chord} clears the editor");
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(format!("{line}\r").into_bytes()),
"{chord} ships the line to the PTY"
);
}
}
#[gpui::test]
fn history_search_off_sends_ctrl_r_to_the_shell(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| {
let mut cfg = cx.global::<Config>().clone();
cfg.history_search = false;
cx.set_global(cfg);
});
window
.update(cx, |view, _, cx| {
view.history = ["git status"].into_iter().map(String::from).collect();
view.history_frecency = vec![0.0; view.history.len()];
view.cmd.set("gi");
view.handle_editor_key(&key("ctrl-r"), cx);
assert!(
view.reverse_search.is_none(),
"no tty7 menu while opted out"
);
assert_eq!(view.cmd.text(), "", "the line went to the shell");
})
.unwrap();
assert_eq!(next_input_until_timeout(&mut daemon), Some(b"gi".to_vec()));
assert_eq!(
next_input_until_timeout(&mut daemon),
Some(vec![0x12]),
"the raw ^R follows the handed-over line"
);
}
#[gpui::test]
fn reverse_search_menu_survives_a_real_render_pass(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
if window
.update(cx, |view, _, _| view.terminal.at_prompt())
.unwrap()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| {
assert!(view.input_active(), "prompt report engages the editor");
view.history = ["git status", "cargo build --release", "echo hello"]
.into_iter()
.map(String::from)
.collect();
view.history_frecency = vec![0.0; view.history.len()];
view.history_meta.insert(
"cargo build --release".into(),
super::super::history::EntryMeta {
ts: Some(unix_now().saturating_sub(7200)),
exit: Some(1),
},
);
view.handle_editor_key(&key("ctrl-r"), cx);
view.commit_text("c", cx);
assert!(
view.reverse_search
.as_ref()
.is_some_and(|rs| !rs.matches().is_empty()),
"the query has matches for the menu to draw"
);
cx.notify();
})
.unwrap();
cx.run_until_parked();
window
.update(cx, |view, _, _| {
assert!(view.reverse_search.is_some(), "search survives the frame");
})
.unwrap();
}
#[gpui::test]
fn submitted_command_backfills_its_exit_code(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
let dir = crate::core::config::config_dir_path().expect("a config dir resolves");
let (window, mut daemon) = harness(cx);
let wait = |cx: &mut TestAppContext, pred: &dyn Fn(&TerminalView) -> bool, what: &str| {
for _ in 0..200 {
if window.update(cx, |view, _, _| pred(view)).unwrap() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("timed out waiting for {what}");
};
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait(cx, &|v| v.terminal.at_prompt(), "the initial prompt report");
let marker = format!("tty7_gpui_exit_marker_{}", std::process::id());
window
.update(cx, |view, _, cx| {
view.cmd.set(&marker);
view.submit_command(cx);
assert!(view.pending_history.is_some(), "record defers for the exit");
})
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: false,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(3),
}
.encode(&mut daemon)
.unwrap();
wait(
cx,
&|v| v.terminal.at_prompt() && v.terminal.last_exit_code() == Some(3),
"the post-command prompt report",
);
window
.update(cx, |view, window, cx| {
view.poll_foreground(window, cx);
assert!(view.pending_history.is_none(), "poll flushed the record");
assert_eq!(
view.history_meta.get(&marker).and_then(|m| m.exit),
Some(3),
"in-memory metadata learned the exit code"
);
})
.unwrap();
let content = std::fs::read_to_string(dir.join("history")).expect("history file written");
let line = content
.lines()
.find(|l| l.contains(&marker))
.expect("the submitted command was recorded");
let mut fields = line.splitn(4, '\t');
let ts = fields.next().unwrap();
assert!(!ts.is_empty() && ts.bytes().all(|b| b.is_ascii_digit()));
assert_eq!(fields.next(), Some("3"), "exit code field");
}
#[gpui::test]
fn meta_word_chords_edit_the_prompt_line(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta = |key: &str| gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: key.to_string(),
key_char: None,
};
view.cmd.set("echo hello");
view.handle_editor_key(&meta("b"), cx);
assert_eq!(view.cmd.cursor(), 5);
view.handle_editor_key(&meta("d"), cx);
assert_eq!(view.cmd.text(), "echo ");
view.handle_editor_key(&meta("b"), cx);
assert_eq!(view.cmd.cursor(), 0);
view.handle_editor_key(&meta("f"), cx);
assert_eq!(view.cmd.cursor(), 4);
view.handle_editor_key(&meta("z"), cx);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
}
fn scroll_into_history(view: &TerminalView, offset: usize) {
let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default();
let mut term = view.terminal.term.lock();
parser.advance(&mut *term, &b"line\r\n".repeat(60));
term.scroll_display(Scroll::Delta(offset as i32));
assert_eq!(
term.grid().display_offset(),
offset,
"the viewport starts parked in the scrollback"
);
}
fn display_offset(view: &TerminalView) -> usize {
view.terminal.term.lock().grid().display_offset()
}
#[gpui::test]
fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = vec!["echo hello".to_string()];
scroll_into_history(view, 10);
view.scroll_frac = 0.5;
view.handle_editor_key(&key("up"), cx);
assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry");
assert_eq!(display_offset(view), 0, "and the viewport followed it down");
assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too");
})
.unwrap();
}
#[gpui::test]
fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "cargo build", "echo hello"]
.into_iter()
.map(String::from)
.collect();
view.handle_editor_key(&key("ctrl-p"), cx);
assert_eq!(view.cmd.text(), "echo hello");
view.handle_editor_key(&key("ctrl-p"), cx);
assert_eq!(view.cmd.text(), "cargo build");
view.handle_editor_key(&key("ctrl-n"), cx);
assert_eq!(view.cmd.text(), "echo hello");
view.handle_editor_key(&key("ctrl-n"), cx);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
}
#[gpui::test]
fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(&key("ctrl-t"), cx);
assert_eq!(
view.cmd.text(),
"",
"the line left for the shell, so the local buffer is empty"
);
assert!(
view.editor_handoff.is_some(),
"the local editor stands down for the rest of the line"
);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell");
}
#[gpui::test]
fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(
&gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: "u".to_string(),
key_char: None,
},
cx,
);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec());
}
#[gpui::test]
fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hello world");
view.handle_editor_key(&key("ctrl-w"), cx);
assert_eq!(view.cmd.text(), "echo hello ");
view.handle_editor_key(&key("ctrl-y"), cx);
assert_eq!(view.cmd.text(), "echo hello world");
assert!(
view.editor_handoff.is_none(),
"the line never left for the shell"
);
})
.unwrap();
}
#[gpui::test]
fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["git status", "cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.cmd.set("ls ");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls world", "newest entry's last word");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls status");
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "ls status");
assert_eq!(view.cmd.cursor(), "ls status".chars().count());
})
.unwrap();
}
#[gpui::test]
fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "world");
view.handle_editor_key(&key("left"), cx);
view.handle_editor_key(&key("end"), cx);
view.handle_editor_key(&meta_dot, cx);
assert_eq!(
view.cmd.text(),
"worldworld",
"a fresh walk appends rather than replacing the earlier word"
);
})
.unwrap();
}
#[gpui::test]
fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.handle_editor_key(&meta_dot, cx);
assert_eq!(view.cmd.text(), "world");
view.commit_text("x", cx);
view.handle_editor_key(&meta_dot, cx);
assert_eq!(
view.cmd.text(),
"worldxworld",
"the typed char survives; the walk starts over after it"
);
})
.unwrap();
}
#[gpui::test]
fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
let meta_dot = gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
..Default::default()
},
key: ".".to_string(),
key_char: None,
};
view.history = ["cargo build --release", "echo hello world"]
.into_iter()
.map(String::from)
.collect();
view.cmd.set("ls foo");
view.cmd.set_cursor(3);
view.cmd.extend_to(6);
view.handle_editor_key(&meta_dot, cx);
assert_eq!(
view.cmd.text(),
"ls world",
"the word replaced the selection"
);
view.handle_editor_key(&meta_dot, cx);
assert_eq!(
view.cmd.text(),
"ls --release",
"the repeat swapped the word, not some other span"
);
})
.unwrap();
}
#[gpui::test]
fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(
&gpui::Keystroke {
modifiers: gpui::Modifiers {
alt: true,
shift: true,
..Default::default()
},
key: "u".to_string(),
key_char: None,
},
cx,
);
assert_eq!(view.cmd.text(), "");
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec());
}
#[gpui::test]
fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set("echo hi");
view.handle_editor_key(&key("ctrl-w"), cx);
assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally");
assert!(view.editor_handoff.is_none());
})
.unwrap();
}
#[gpui::test]
fn pty_write_events_reach_the_daemon_as_input(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.handle_event(AlacEvent::PtyWrite("ping".into()), cx);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"ping".to_vec());
}
fn bind_to_a_disconnected_remote_workspace(
view: &mut TerminalView,
cx: &mut Context<TerminalView>,
) -> crate::core::session::WorkspaceId {
use crate::core::session::{
RemoteRef, RemoteTarget, WindowViews, WorkspaceId, WorkspaceStore,
};
use crate::terminal::PaneWorkspace;
let host = RemoteRef::new(
RemoteTarget::direct("me", "build-box", 22),
WorkspaceId::new(),
);
let entry = crate::core::session::WindowView::on_remote(host.clone());
let id = entry.id;
WorkspaceStore::install_for_test(
cx,
WindowViews {
views: vec![entry],
active: None,
},
);
view.set_workspace(Some(PaneWorkspace {
workspace: id,
target: host.target,
spec: Some(Box::new(
serde_json::from_str(
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
)
.unwrap(),
)),
}));
id
}
#[gpui::test]
fn a_disconnected_remote_pane_keeps_the_line_instead_of_handing_it_to_nowhere(
cx: &mut TestAppContext,
) {
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
cx.update(|cx| crate::ui::keymap::init(cx));
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
wait_for_input_active(&window, cx);
window
.update(cx, |view, window, cx| {
window.activate_window();
view.focus_handle.focus(window, cx);
view.cmd.set("zzqqx");
bind_to_a_disconnected_remote_workspace(view, cx);
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.simulate_keystrokes("tab");
window
.update(cx, |view, _, cx| {
assert_eq!(
view.cmd.text(),
"zzqqx",
"a Tab dispatched through SendTab must not empty the line"
);
assert!(
view.editor_handoff.is_none(),
"nothing was handed off, so the editor keeps the prompt"
);
view.submit_command(cx);
assert_eq!(
view.cmd.text(),
"zzqqx",
"submit_command guards the link too, even though on_key_down already does"
);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"not one byte reached the wire"
);
}
#[gpui::test]
fn a_tab_on_a_detached_remote_pane_never_asks_for_a_listing(cx: &mut TestAppContext) {
use std::io::Write as _;
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: None,
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Cwd(std::path::PathBuf::from("/home/me/proj"))
.encode(&mut daemon)
.unwrap();
daemon.flush().unwrap();
wait_for_input_active(&window, cx);
for _ in 0..200 {
if window
.update(cx, |view, _, _| view.cwd().is_some())
.unwrap()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| {
view.cmd.set("ls /home/me/");
bind_to_a_disconnected_remote_workspace(view, cx);
assert!(
view.remote_ssh_cwd().is_some(),
"the pane has to look remote enough to want a listing at all"
);
view.tab_pressed(true, cx);
assert!(
!view.remote_completion_inflight,
"a Tab must not send an SFTP listing down a link that is not attached"
);
assert_eq!(
view.cmd.text(),
"ls /home/me/",
"and the line stays where it was"
);
})
.unwrap();
}
#[gpui::test]
fn a_disconnected_remote_pane_swallows_every_kind_of_typing(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, window, cx| {
bind_to_a_disconnected_remote_workspace(view, cx);
type_char(view, "x", window, cx);
view.commit_text("y", cx);
view.paste("pasted".into(), cx);
view.send_to_pty(b"raw", cx);
view.dump_hold(0, cx);
})
.unwrap();
assert_eq!(
next_input_until_timeout(&mut daemon),
None,
"a read-only window must not put one byte of typing on the wire"
);
}
#[gpui::test]
fn a_disconnected_remote_pane_still_selects_and_copies(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Output(b"secrets".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..400 {
cx.run_until_parked();
let ready = window
.update(cx, |view, _, _| {
let term = view.terminal.term.clone();
let term = term.lock();
term.grid()[alacritty_terminal::index::Line(0)][Column(0)].c == 's'
})
.unwrap();
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let chord = |key: &str| KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: Modifiers {
platform: true,
..Modifiers::default()
},
key: key.into(),
key_char: None,
},
is_held: false,
prefer_character_input: false,
};
window
.update(cx, |view, window, cx| {
bind_to_a_disconnected_remote_workspace(view, cx);
view.terminal.exited = true;
view.on_key_down(&chord("a"), window, cx);
assert!(
view.terminal.term.lock().selection.is_some(),
"⌘A must still select on a read-only window"
);
view.on_key_down(&chord("c"), window, cx);
})
.unwrap();
let copied = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
assert!(
copied.is_some_and(|t| t.contains("secrets")),
"⌘C must still copy on a read-only window"
);
}
#[gpui::test]
fn a_disconnected_remote_pane_still_answers_terminal_queries(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
bind_to_a_disconnected_remote_workspace(view, cx);
view.handle_event(AlacEvent::PtyWrite("\x1b[?62;c".into()), cx);
})
.unwrap();
assert_eq!(
next_input(&mut daemon),
b"\x1b[?62;c".to_vec(),
"a query reply is the emulator's answer, not the user's typing"
);
}
#[gpui::test]
fn a_dropped_link_does_not_claim_the_process_exited(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
bind_to_a_disconnected_remote_workspace(view, cx);
view.handle_event(AlacEvent::Exit, cx);
assert_eq!(view.title, "tty7 — disconnected");
view.set_workspace(None);
view.handle_event(AlacEvent::Exit, cx);
assert_eq!(view.title, "tty7 — process exited");
})
.unwrap();
}
#[gpui::test]
fn an_exited_local_pane_still_swallows_every_key(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, window, cx| {
view.terminal.exited = true;
let cmd_a = KeyDownEvent {
keystroke: gpui::Keystroke {
modifiers: Modifiers {
platform: true,
..Modifiers::default()
},
key: "a".into(),
key_char: None,
},
is_held: false,
prefer_character_input: false,
};
view.on_key_down(&cmd_a, window, cx);
assert!(
view.terminal.term.lock().selection.is_none(),
"an exited local pane is finished; its keyboard is unchanged"
);
})
.unwrap();
}
#[gpui::test]
fn a_local_pane_types_exactly_as_it_always_did(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, window, cx| {
bind_to_a_disconnected_remote_workspace(view, cx);
view.set_workspace(None);
assert!(view.accepts_input(cx));
type_char(view, "z", window, cx);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"z".to_vec());
}
#[gpui::test]
fn a_relink_moves_the_pane_onto_the_new_socket_and_resets_the_mirror(cx: &mut TestAppContext) {
let (window, mut old_daemon) = harness(cx);
let read_row = |cx: &mut TestAppContext, len: usize| -> String {
window
.update(cx, |view, _, _| {
let term = view.terminal.term.clone();
let term = term.lock();
let grid = term.grid();
(0..len)
.map(|c| grid[alacritty_terminal::index::Line(0)][Column(c)].c)
.collect()
})
.unwrap()
};
DaemonMsg::Output(b"before".to_vec())
.encode(&mut old_daemon)
.unwrap();
let mut seen = String::new();
for _ in 0..400 {
cx.run_until_parked();
seen = read_row(cx, 6);
if seen == "before" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(seen, "before", "the pre-drop screen is what we relink over");
let (new_client, mut new_daemon) = UnixStream::pair().unwrap();
window
.update(cx, |view, _, cx| {
view.adopt_relink(
new_client,
&crate::terminal::PaneRoute::Local,
TermSize::new(100, 30),
8,
17,
cx,
)
.expect("the swap itself cannot fail");
assert_eq!(
view.title, "tty7",
"a relinked pane is not \"process exited\""
);
})
.unwrap();
assert_ne!(
read_row(cx, 6),
"before",
"the mirror must be reset before the daemon replays onto it"
);
let resize = loop {
match ClientMsg::read(&mut new_daemon).expect("the new socket is live") {
ClientMsg::Resize(win) => break win,
_ => continue,
}
};
assert_eq!((resize.cols, resize.rows), (100, 30));
window
.update(cx, |view, _, cx| view.send_to_pty(b"after", cx))
.unwrap();
assert_eq!(next_input(&mut new_daemon), b"after".to_vec());
let mut leftovers: Vec<Vec<u8>> = Vec::new();
loop {
match ClientMsg::read(&mut old_daemon) {
Ok(ClientMsg::Input(bytes)) => leftovers.push(bytes),
Ok(_) => continue,
Err(_) => break,
}
}
assert!(
leftovers.is_empty(),
"the retired socket must never see another byte: {leftovers:?}"
);
}
#[gpui::test]
fn buffer_search_honors_case_and_regex_toggles(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Output(b"Hello World\r\nhello world\r\nWORLD wide\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
let ready = window
.update(cx, |v, _, _| {
let term = v.terminal.term.lock();
let grid = term.grid();
(0..grid.screen_lines() as i32)
.any(|l| (0..grid.columns()).any(|c| grid[Line(l)][Column(c)].c == 'W'))
})
.unwrap();
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
fn set_query(
view: &mut TerminalView,
q: &str,
window: &mut Window,
cx: &mut Context<TerminalView>,
) {
let input = view.search.as_ref().unwrap().input.clone();
input.update(cx, |s, cx| s.set_value(q, window, cx));
view.recompute_matches(cx);
}
view.open_search(window, cx);
assert!(view.search.is_some(), "Cmd+F opens the bar");
set_query(view, "world", window, cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 3);
assert!(!view.search_regex_error);
view.search_case_sensitive = true;
view.recompute_matches(cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 1);
view.search_case_sensitive = false;
set_query(view, "wor.d", window, cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 0);
view.search_regex = true;
view.recompute_matches(cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 3);
view.search_regex = true;
set_query(view, "(", window, cx);
assert!(view.search_regex_error);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 0);
view.search_regex = false;
view.recompute_matches(cx);
assert!(!view.search_regex_error);
view.close_search(window, cx);
assert_eq!(view.search_last_query, "(");
assert!(view.search.is_none());
})
.unwrap();
}
#[gpui::test]
fn child_exit_marks_the_view_exited(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.handle_event(AlacEvent::Exit, cx);
assert!(view.terminal.exited);
assert_eq!(view.title, "tty7 — process exited");
})
.unwrap();
}
#[gpui::test]
fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
let want = window
.update(cx, |view, _, cx| {
let size = view.terminal.size();
let fmt = std::sync::Arc::new(|ws: alacritty_terminal::event::WindowSize| {
format!("{}x{}", ws.num_cols, ws.num_lines)
});
view.handle_event(AlacEvent::TextAreaSizeRequest(fmt), cx);
format!("{}x{}", size.cols, size.rows)
})
.unwrap();
assert_eq!(next_input(&mut daemon), want.into_bytes());
}
#[gpui::test]
fn daemon_output_reaches_the_grid_through_the_event_pump(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
let read_row = |cx: &mut TestAppContext, len: usize| -> String {
window
.update(cx, |view, _, _| {
let term = view.terminal.term.clone();
let term = term.lock();
let grid = term.grid();
(0..len)
.map(|c| grid[alacritty_terminal::index::Line(0)][Column(c)].c)
.collect()
})
.unwrap()
};
let wait_for = |cx: &mut TestAppContext, want: &str| {
let mut got = String::new();
for _ in 0..400 {
cx.run_until_parked();
got = read_row(cx, want.chars().count());
if got == want {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
got
};
DaemonMsg::Output(b"hello".to_vec())
.encode(&mut daemon)
.unwrap();
assert_eq!(wait_for(cx, "hello"), "hello");
DaemonMsg::Output(b" again".to_vec())
.encode(&mut daemon)
.unwrap();
assert_eq!(wait_for(cx, "hello again"), "hello again");
}
#[gpui::test]
fn copy_on_select_writes_the_clipboard_at_mouse_up(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Output(b"hello world".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..400 {
cx.run_until_parked();
let row: String = window
.update(cx, |view, _, _| {
let term = view.terminal.term.clone();
let term = term.lock();
(0..11)
.map(|c| term.grid()[alacritty_terminal::index::Line(0)][Column(c)].c)
.collect()
})
.unwrap();
if row == "hello world" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let drag_hello = |cx: &mut TestAppContext| {
window
.update(cx, |view, _, cx| {
view.on_select_start(0, 0, true, 1, false, cx);
view.on_select_update(4, 0, false, cx);
view.on_select_end(cx);
})
.unwrap();
};
drag_hello(cx);
assert_eq!(
cx.update(|cx| cx.read_from_clipboard()),
None,
"default-off must never write the clipboard"
);
cx.update(|cx| cx.update_global::<Config, _>(|cfg, _| cfg.copy_on_select = true));
drag_hello(cx);
let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
assert_eq!(text.as_deref(), Some("hello"));
let selected = window
.update(cx, |view, _, _| {
view.terminal.term.lock().selection.is_some()
})
.unwrap();
assert!(
selected,
"copy-on-select must keep the selection highlighted"
);
}
#[gpui::test]
fn ctrl_c_copy_consumes_the_selection_so_the_next_press_is_sigint(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Output(b"hello world".to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..400 {
cx.run_until_parked();
let row: String = window
.update(cx, |view, _, _| {
let term = view.terminal.term.clone();
let term = term.lock();
(0..11)
.map(|c| term.grid()[alacritty_terminal::index::Line(0)][Column(c)].c)
.collect()
})
.unwrap();
if row == "hello world" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
view.on_select_start(0, 0, true, 1, false, cx);
view.on_select_update(4, 0, false, cx);
view.on_select_end(cx);
assert!(view.has_selection(), "the drag must leave a selection");
let consumed = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx);
assert!(matches!(consumed, CmdKey::Consumed));
assert!(
!view.has_selection(),
"the Ctrl+C copy must consume the selection"
);
let fell_through = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx);
assert!(matches!(fell_through, CmdKey::FallThrough));
})
.unwrap();
let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
assert_eq!(text.as_deref(), Some("hello"));
}
#[gpui::test]
fn paste_to_the_pty_consumes_the_selection(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.select_all(cx);
assert!(view.has_selection());
view.paste("echo hi".into(), cx);
assert!(
!view.has_selection(),
"a PTY paste must consume the selection"
);
})
.unwrap();
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
}
#[gpui::test]
fn ctrl_c_copy_consumes_the_editor_selection_at_the_prompt(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
for _ in 0..400 {
cx.run_until_parked();
let active = window.update(cx, |view, _, _| view.input_active()).unwrap();
if active {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
assert!(view.input_active(), "the inline editor must be active");
view.cmd.insert_str("echo hi");
view.cmd.select_all();
let consumed = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx);
assert!(matches!(consumed, CmdKey::Consumed));
assert!(
view.cmd.selection().is_none(),
"the Ctrl+C copy must consume the editor selection"
);
let fell_through = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx);
assert!(matches!(fell_through, CmdKey::FallThrough));
})
.unwrap();
let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
assert_eq!(text.as_deref(), Some("echo hi"));
}
#[gpui::test]
fn hidden_cursor_at_prompt_anchors_the_editor_at_the_real_cell_not_top_left(
cx: &mut TestAppContext,
) {
use alacritty_terminal::vte::ansi::CursorShape;
let (window, mut daemon) = harness(cx);
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
DaemonMsg::Output(b"\x1b[4;11H\x1b[?25l".to_vec())
.encode(&mut daemon)
.unwrap();
let mut state = (false, false, None);
for _ in 0..400 {
cx.run_until_parked();
state = window
.update(cx, |view, _, _| {
let hidden = matches!(
view.terminal.term.lock().renderable_content().cursor.shape,
CursorShape::Hidden
);
(view.input_active(), hidden, view.cursor_cell())
})
.unwrap();
if state == (true, true, Some((3, 10))) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (active, hidden, cell) = state;
assert!(
active,
"shell at its prompt must make the inline editor active"
);
assert!(
hidden,
"the TUI's `?25l` must leave the cursor shape Hidden"
);
assert_eq!(
cell,
Some((3, 10)),
"a Hidden shape must not collapse the editor anchor to the top-left corner"
);
}
#[gpui::test]
fn child_exit_emits_the_close_event_but_disconnect_does_not(cx: &mut TestAppContext) {
use std::cell::Cell;
use std::rc::Rc;
let subscribe = |window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext| {
let got = Rc::new(Cell::new(false));
let seen = got.clone();
window
.update(cx, |_, _, cx| {
let this = cx.entity();
cx.subscribe(&this, move |_, _, _: &ChildExited, _| seen.set(true))
.detach();
})
.unwrap();
got
};
let wait_exited = |window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext| {
for _ in 0..400 {
cx.run_until_parked();
let exited = window
.update(cx, |view, _, _| view.terminal.exited)
.unwrap();
if exited {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("the view never noticed the exit");
};
let (window, mut daemon) = harness(cx);
let got = subscribe(&window, cx);
DaemonMsg::Exited { code: Some(0) }
.encode(&mut daemon)
.unwrap();
wait_exited(&window, cx);
assert!(got.get(), "a genuine child exit must emit ChildExited");
let (window, daemon) = harness(cx);
let got = subscribe(&window, cx);
drop(daemon);
wait_exited(&window, cx);
assert!(!got.get(), "a daemon disconnect must not emit ChildExited");
}
#[gpui::test]
fn ssh_drop_mid_tui_recovers_at_the_next_prompt(cx: &mut TestAppContext) {
use alacritty_terminal::vte::ansi::CursorShape;
let (window, mut daemon) = harness(cx);
DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l".to_vec())
.encode(&mut daemon)
.unwrap();
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(255),
}
.encode(&mut daemon)
.unwrap();
let mut state = (false, true, true);
for _ in 0..400 {
cx.run_until_parked();
state = window
.update(cx, |view, _, _| {
let hidden = matches!(
view.terminal.term.lock().renderable_content().cursor.shape,
CursorShape::Hidden
);
(view.at_shell_prompt(), view.on_alt_screen(), hidden)
})
.unwrap();
if state == (true, false, false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (at_prompt, on_alt, hidden) = state;
assert!(at_prompt, "the host shell is back at its prompt");
assert!(
!on_alt,
"the prompt report must pull the grid off the stranded alt screen"
);
assert!(
!hidden,
"the prompt report must re-show the DECTCEM-hidden cursor"
);
window
.update(cx, |view, _, _| {
assert!(
view.input_active(),
"off the alt screen and at the prompt, the editor is live"
);
})
.unwrap();
}
#[gpui::test]
fn generator_results_merge_into_the_open_menu(cx: &mut TestAppContext) {
use crate::terminal::generator::Parsed;
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set_with_cursor("git checkout ma", 15);
let session = CompletionSession::new(13, String::new(), Vec::new());
let generation = view.open_completion(session);
let results = vec![
Parsed {
text: "main".into(),
description: Some("branch".into()),
},
Parsed {
text: "mainline".into(),
description: Some("branch".into()),
},
Parsed {
text: "feature".into(),
description: None,
},
];
view.completion_merge(generation, results, cx);
let s = view.completion.as_ref().expect("menu still open");
let shown: Vec<&str> = s.filtered.iter().map(|&i| s.all[i].text.as_str()).collect();
assert_eq!(shown, vec!["main", "mainline"]);
assert_eq!(s.selected().unwrap().text, "main");
})
.unwrap();
}
#[gpui::test]
fn generator_result_for_a_closed_menu_is_dropped(cx: &mut TestAppContext) {
use crate::terminal::generator::Parsed;
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.cmd.set_with_cursor("git checkout ", 13);
let session = CompletionSession::new(13, String::new(), Vec::new());
let stale = view.open_completion(session);
view.close_completion();
view.completion_merge(
stale,
vec![Parsed {
text: "main".into(),
description: None,
}],
cx,
);
assert!(
view.completion.is_none(),
"a result for a closed session never reopens the menu"
);
let fresh =
view.open_completion(CompletionSession::new(13, String::new(), Vec::new()));
assert_ne!(stale, fresh);
view.completion_merge(
stale,
vec![Parsed {
text: "main".into(),
description: None,
}],
cx,
);
let s = view.completion.as_ref().unwrap();
assert!(
s.all.is_empty(),
"the stale result stayed out of the new menu"
);
})
.unwrap();
}
#[gpui::test]
fn a_remote_workspace_pane_reports_its_cwd_as_remote(cx: &mut TestAppContext) {
use std::io::Write as _;
let (window, mut daemon) = harness(cx);
DaemonMsg::Cwd(std::path::PathBuf::from("/home/me/proj"))
.encode(&mut daemon)
.unwrap();
daemon.flush().unwrap();
for _ in 0..200 {
let seen = window
.update(cx, |view, _, _| view.cwd().is_some())
.unwrap();
if seen {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| {
assert_eq!(
view.local_cwd(),
Some(std::path::PathBuf::from("/home/me/proj"))
);
assert_eq!(view.remote_ssh_cwd(), None);
bind_to_a_disconnected_remote_workspace(view, cx);
assert!(
view.remote_context().is_none(),
"the far daemon reports a plain local pane — if this ever \
stops holding, the binding below is no longer the only signal"
);
assert_eq!(
view.local_cwd(),
None,
"a routed pane's cwd is not a path on this machine"
);
assert_eq!(
view.remote_ssh_cwd(),
Some("/home/me/proj".to_string()),
"Tab must ask the workspace's connection about it"
);
})
.unwrap();
}
}