Files
tty7/src/terminal/view.rs
T
thomasandClaude Opus 5 d3549bce23 test(terminal): pin the geometry the resize test starts from
The new hover-invalidation test read 24 rows into the pane because the
harness names that size, but the test window lays itself out first and
`set_grid_size` had already moved the terminal to the measured geometry
— so the "same size changes nothing" call was a size *change*, and it
cleared the cell the assert expected. Set the starting geometry from the
test instead of inheriting whatever the window measured. Unix-only
module, so this only shows up in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-27 09:41:54 +08:00

8450 lines
363 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.
//! The GPUI view that hosts a terminal: owns the backend, pumps PTY events into
//! redraws, translates keystrokes to bytes, and renders the terminal chrome.
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;
use gpui_component::{ActiveTheme as _, Icon, IconName, 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, NewTab, SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane,
};
use crate::core::config::{BellMode, Config, NotifyMode};
use crate::daemon::protocol::{RemoteContext, ShellSpec};
/// Inset (px) between the terminal-surface edge and the cell grid. The prompt
/// editor and the floating completion / history menus are absolutely positioned
/// over the grid, so they must offset their grid-aligned origin by the same
/// amount the surface padding insets the grid. Keep these in sync with the
/// `.px()/.py()` on the surface container in `TerminalView::render` — they are
/// the single source of truth for that inset.
const GRID_PAD_X: f32 = 8.;
const GRID_PAD_Y: f32 = 4.;
// Terminal-scoped actions dispatched by the right-click context menu and the
// menu bar's Edit menu. They route to this view via `.on_action` handlers on the
// terminal surface; tab/split actions in the same menu bubble up to `Tty7App`
// from the focused terminal.
//
// Every one of these is the *single* path for its gesture: the ⌘-chord, the
// context-menu row, and the Edit-menu item all dispatch the same action, so the
// three can't drift (they did — the context menu's Paste used to skip the
// image-paste branch that ⌘V had).
actions!(
terminal,
[
CopyText,
CutText,
PasteText,
SelectAll,
UndoEdit,
RedoEdit,
FindInTerminal,
FindNext,
FindPrevious,
ClearScrollback
]
);
/// Emitted when the pane's child process has genuinely exited (`exit`,
/// Ctrl-D, a crashed shell) — as opposed to the daemon connection dropping,
/// which keeps the dead pane visible. `Tty7App` subscribes (see
/// `new_terminal`) and closes the pane in response: collapsing its split, or
/// closing the tab when it was the only pane.
pub struct ChildExited;
impl gpui::EventEmitter<ChildExited> for TerminalView {}
/// A native-SSH pane raised an interactive auth/host-key prompt (or a status
/// change) that the app should surface in an in-pane sheet. `Tty7App` subscribes
/// (see `new_terminal`) and drains the pane's pending prompts into
/// `ui::ssh_prompt`. Zero-payload — the app reads the prompt off the pane's
/// `RemoteTerminal` (`take_auth_prompt` / `ssh_phase`).
pub struct AuthPromptReady;
impl gpui::EventEmitter<AuthPromptReady> for TerminalView {}
/// An established native-SSH daemon pane, ready to be wrapped in a view: the
/// output of the fallible [`TerminalView::spawn_native_ssh_terminal`], consumed
/// by the infallible [`TerminalView::from_native_ssh_parts`].
pub struct NativeSshParts {
terminal: RemoteTerminal,
pane_id: u64,
/// Secret-free spec copy retained for session restore / in-pane reconnect.
persist: Box<crate::daemon::protocol::NativeSshSpec>,
}
/// An established shell daemon pane (fresh spawn or re-attach), ready to be
/// wrapped in a view: the output of the fallible
/// [`TerminalView::spawn_shell_terminal`], consumed by the infallible
/// [`TerminalView::from_shell_parts`].
pub struct ShellParts {
terminal: RemoteTerminal,
pane_id: u64,
/// The explicit shell pick this pane was spawned with, if any; `None` for a
/// re-attached pane (the pick isn't persisted).
shell_spec: Option<ShellSpec>,
}
/// See `TerminalView::drag_scroll`.
#[derive(Clone, Copy)]
struct DragScroll {
/// How far past the pane edge the pointer sits, in lines. Positive =
/// above the top edge (scroll up into history), negative = below.
overshoot: f32,
/// Column to keep extending the selection with at the edge row.
col: usize,
/// Cell half to anchor the selection end on.
side: Side,
}
pub struct TerminalView {
pub terminal: RemoteTerminal,
/// Daemon-assigned id of the pane this view mirrors. Persisted in the session
/// so a restart can re-`attach` to the still-running pane (process + scrollback
/// intact) instead of spawning a fresh shell.
pub pane_id: u64,
/// The shell this pane was spawned with when the user picked one from the
/// new-tab dropdown; `None` for the default shell and for re-attached
/// panes. In-memory only (not persisted) — held so splits of this pane
/// inherit the same shell.
shell_spec: Option<ShellSpec>,
/// The native-SSH spec this pane was spawned with, **secrets stripped**
/// ([`NativeSshSpec::without_secrets`]). `None` for local shells (and a
/// foreground `ssh` typed in one). Persisted into the session so a *dead*
/// native-SSH pane can be respawned/reconnected on restore (PRD FR-E4 / C2),
/// and read live to drive the in-pane reconnect (`RestartSshSession`).
ssh_spec: Option<Box<crate::daemon::protocol::NativeSshSpec>>,
pub focus_handle: FocusHandle,
pub font: Font,
/// Optional distinct base face for bold cells (from `font_family_bold`), with
/// the same fallback chain as `font`. `None` → synthesize bold from `font`.
pub font_bold: Option<Font>,
/// Optional distinct base face for italic cells (from `font_family_italic`).
/// `None` → synthesize italic from `font`.
pub font_italic: Option<Font>,
/// User-configured OpenType features for terminal fonts. `None` preserves
/// tty7's terminal-safe default (ligatures disabled); `Some` is opt-in.
font_features: Option<gpui::FontFeatures>,
pub font_size: Pixels,
/// Line height as a multiple of `font_size`; the element turns it into the
/// concrete row height each frame. Sourced from `Config::line_height`.
pub line_height_mul: f32,
pub cell_width: Pixels,
line_height: Pixels,
selecting: bool,
/// Auto-scroll state for a selection drag that has crossed the pane's top
/// or bottom edge; `None` while the pointer is inside. A repeating task
/// (armed by `select_autoscroll`) keeps scrolling the scrollback and
/// re-extending the selection while this is `Some`, so the scroll goes on
/// even when the pointer holds still past the edge — mouse-move events
/// alone stop the moment the hand does.
drag_scroll: Option<DragScroll>,
/// Generation counter for the auto-scroll task. Bumped every time a new
/// task is armed so a stale task from a just-cancelled edge visit kills
/// itself instead of doubling the scroll speed when the pointer leaves,
/// re-enters, and leaves the pane again within one tick.
drag_scroll_epoch: u64,
pub title: String,
/// IME pre-edit (composing) text, e.g. the pinyin shown before a Chinese
/// candidate is committed. Empty when not composing.
pub marked_text: String,
/// Last cell reported to the PTY in mouse-tracking mode, used to suppress
/// duplicate motion reports while dragging within a single cell.
last_mouse_cell: Option<(usize, usize)>,
/// Last cell the pointer hovered over locally. Kept separate from
/// `last_mouse_cell`, which belongs to terminal mouse-reporting protocol
/// state and must not be disturbed by local link affordances.
last_hover_cell: Option<(usize, usize)>,
/// Whether the platform modifier (⌘ on macOS) is currently held, as reported
/// by the window-level modifier listener. Mouse events can lag or omit this
/// state while a mouse-tracking TUI is foreground, so link hover must not
/// depend solely on each move event's modifier snapshot.
link_modifier_down: bool,
/// Fractional line debt carried between wheel events on the quantized
/// paths (mouse-tracking reports, alternate-scroll arrow keys), where the
/// app consumes whole lines. Trackpads report pixel deltas well under a
/// line per event; rounding each one separately discards them all and slow
/// scrolling never moves. Accumulate instead and spend whole lines as they
/// build up.
scroll_debt: f32,
/// Sub-line part of the scrollback position, in lines (`0.0..1.0`). The
/// emulator's `display_offset` holds the whole lines; together they form a
/// continuous, pixel-smooth scroll position. The element shifts the whole
/// grid down by `scroll_frac * line_height` at paint and fills the strip
/// above with the next older row, so trackpad scrolling moves every frame
/// instead of snapping line by line. Reset to 0 whenever something jumps
/// the view (typing, submit, clear).
pub(super) scroll_frac: f32,
/// In-progress incremental search (Cmd+F), if the search bar is open.
pub search: Option<SearchState>,
/// Whether the block cursor is in its "on" (drawn) phase. Toggled by the
/// blink task while focused, and forced back to `true` on input / focus so
/// the cursor never lingers in the hidden phase right after the user acts.
pub cursor_visible: bool,
/// Whether this terminal currently holds keyboard focus. Kept in sync via
/// focus listeners so the blink task pauses while unfocused (where the
/// cursor is drawn as a hollow box instead of blinking).
pub focused: bool,
/// Whether the search field currently holds focus. Kept in sync from the
/// field's `Focus`/`Blur` events; lets Escape close the bar while focused and
/// keeps Escape feeding the PTY when the terminal is focused.
/// `pub(super)` so the search code in `terminal::search` can mirror focus.
pub(super) search_focused: bool,
/// Force case-sensitive matching (the "Aa" toggle). When `false` the query
/// keeps alacritty's smart-case default (insensitive unless it contains an
/// uppercase char); when `true` a `(?-i)` prefix forces sensitivity. Persists
/// across close/reopen of the bar.
pub(super) search_case_sensitive: bool,
/// Treat the query as a regex (the ".*" toggle). When `false` (default) the
/// query is matched literally (metacharacters escaped); when `true` it is a
/// regex pattern. Persists across close/reopen.
pub(super) search_regex: bool,
/// Set when the current query is regex mode and fails to compile — drives the
/// error styling on the search field so an invalid pattern isn't a silent
/// zero-match. Only ever true while `search_regex` is on.
pub(super) search_regex_error: bool,
/// The last query text, remembered when the bar closes so reopening restores
/// it (unless a selection prefills instead).
pub(super) search_last_query: String,
/// True for a brief window after a bell event; drives a momentary visual
/// flash painted in place of an audible beep.
pub bell_flash: bool,
/// Whether mouse events are reported to full-screen apps that request it
/// (`Config::mouse_reporting`). Cached from the global at construction and
/// refreshed on config hot-reload (`Tty7App::reload_from_config`) so the
/// mouse-report gates — which run in `&self`/`&mut self` methods without a
/// `cx` — can consult it. When `false`, every mouse-tracking mode reads as
/// clear, keeping the mouse local (selection + scrollback).
pub report_mouse: bool,
/// Last observed "shell is idle at its prompt" state, tracked so a change can
/// trigger a redraw (showing/hiding the line editor) even when the shell
/// produced no output to repaint on its own.
last_at_prompt: bool,
/// When a foreground command is running, the instant it started and the tab
/// title captured then — used to fire a "command finished" notification for
/// long-running commands completed while the window is in the background.
running_since: Option<std::time::Instant>,
running_title: String,
/// The coding agent (if any) detected during the current foreground-command
/// episode, captured so its completion notification can be branded ("Claude
/// Code finished" rather than a generic "command finished"). Set the moment
/// the daemon reports an agent while a command runs; cleared when it ends.
running_agent: Option<crate::core::cli_agent::CLIAgent>,
/// The rich agent status last seen by the poll, so transitions (working →
/// waiting, working → done) fire exactly one notification each and repaint
/// the status dot.
last_agent_status: Option<crate::core::cli_agent::AgentStatus>,
/// When the current rich turn entered `Working`, for the "finished after
/// Ns" copy on its `Done` notification.
agent_turn_started: Option<std::time::Instant>,
/// Whether this pane's agent ever reported over the rich sentinel channel.
/// While true, the coarse process-exit "agent finished" notification is
/// suppressed — the turn-level `stop` events already said it better.
agent_was_rich: bool,
/// Whether the agent's last finished turn (the green `Done` dot) is *unread*
/// — a turn ended that the user hasn't looked at since. Set when a new turn
/// finishes while this pane is unfocused; cleared the moment the pane gains
/// focus (you're looking at it). The tab avatar only paints the Done dot
/// while this is true, so a result you've already seen stops nagging. Blue
/// (working) / amber (waiting) are unaffected — they track live state.
agent_result_unread: bool,
/// One-shot guard for a manual "Mark as Unread" on the pane the dismissed
/// context menu is about to refocus: closing the menu returns window focus
/// to that pane, and the resulting focus-in would instantly clear the mark
/// the user just made. Armed by [`mark_agent_result_unread`]
/// (Self::mark_agent_result_unread); the next focus-in consumes it instead
/// of clearing, so the mark survives until the user genuinely comes back.
keep_unread_on_focus: bool,
/// The cwd this pane's git line reads from (and last scheduled a probe
/// for), so the poll loop only reprobes when the working directory
/// actually changes. The snapshot itself lives in the process-wide
/// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), keyed
/// by work-tree root — panes in one repo share one entry instead of each
/// computing (and staling) its own.
git_status_cwd: Option<std::path::PathBuf>,
/// The agent session's tool-completion count as of the last poll, so a
/// change means "the agent ran a tool since we looked" — the cue to refresh
/// the git line mid-turn rather than at the end of one. Reset to 0 when no
/// session is present, so a new session's first tool call reads as activity.
last_agent_activity: u64,
/// The inline command line editor. Live only while the shell sits idle
/// at its prompt (`input_active`): there the terminal keeps keyboard focus and
/// we run our own line editor (so we own Tab / ↑ / ↓ for completion and
/// history, which a focused `InputState` would otherwise claim). On Enter the
/// whole edited line is shipped to the PTY at once. While a command runs (or on
/// the alternate screen) it's hidden and keys feed the PTY directly.
cmd: CmdEditor,
/// Reconstruction of input typed while the line editor is disengaged —
/// shell startup (rc sourcing) and the gap while every command runs. Those
/// keys bypass the editor, queue in the TTY, and zle consumes them at the
/// next prompt as un-editable strays that the editor overlay then
/// double-draws over. Drained (^U + editor seed) once the editor is live
/// *and* zle is reading (`zle_reading`). See `typeahead` module docs.
typeahead: Typeahead,
/// Short client-side hold for reconstructable gap input: a fast command's
/// typeahead goes straight to the editor without ever touching the PTY
/// (no kernel echo, no wipe); a lapsed window (`HOLD_WINDOW`) releases the
/// bytes for whatever reads stdin. See `hold` module docs.
hold: GapHold,
/// Commands submitted this session, oldest first — the source for ↑/↓ recall
/// and Ctrl+R search (both of which want strict chronological order).
history: Vec<String>,
/// How many times each history line has been run (across the shell histories,
/// tty7's own file, and this session). The frequency half of the frecency
/// ranking; kept in step with `history` on submit.
history_counts: std::collections::HashMap<String, u32>,
/// For each history line, the set of directories it was run in — the
/// current-directory half of the frecency ranking, so commands used *here*
/// float up. Kept in step with `history` on submit.
history_cwds: std::collections::HashMap<String, std::collections::HashSet<String>>,
/// Last-run metadata (timestamp + exit code) per history line, feeding the
/// Ctrl+R menu's "ran 3h ago" and failure badges. Kept in step with
/// `history` on submit; the exit code lands when the shell reports back.
history_meta: std::collections::HashMap<String, super::history::EntryMeta>,
/// `history` re-ordered by frecency (frequency × recency + a current-directory
/// bonus), most relevant first. Drives the ghost-text autosuggestion — the
/// sole whole-line recall surface besides Ctrl+R (the Tab menu stays
/// history-free). Recomputed when a command is run or the working directory
/// changes.
history_ranked: Vec<String>,
/// The frecency score of each `history` entry, index-aligned with it — the
/// relevance half of the Ctrl+R search's fuzzy+frecency blend. Recomputed
/// alongside `history_ranked`.
history_frecency: Vec<f64>,
/// The directory `history_ranked` was last computed for, so the polling loop
/// only re-ranks when the working directory actually changes.
ranked_cwd: Option<std::path::PathBuf>,
/// Current position while navigating history with ↑/↓: `Some(i)` indexes
/// `history`; `None` means we're editing a fresh line (past the newest entry).
history_nav: Option<usize>,
/// The in-progress line saved when history navigation starts, so pressing ↓
/// past the newest entry restores what the user was typing.
history_stash: String,
/// A submitted command whose history-file record is deferred until the
/// shell reports back at its prompt, so the record can carry the command's
/// exit code (see [`PendingHistory`]).
pending_history: Option<PendingHistory>,
/// Open Tab-completion menu, if any — a picker over the candidates gathered
/// when it opened. Typing/Backspace re-filter it in place; it closes on
/// accept, on Escape, or once the edited word no longer matches anything.
completion: Option<CompletionSession>,
/// Monotonic tag bumped every time a completion session opens or closes.
/// Dynamic generators run on background threads and land their results here
/// via `cx.spawn`; each task captures the generation it was spawned under and
/// its result is dropped unless it still matches — so output from a session
/// the user has since closed (or replaced) can never leak into a later menu.
completion_generation: u64,
/// While equal to the terminal's current `prompt_cycle`, the local line
/// editor has handed this prompt's line over to the shell (Tab fell
/// through to shell-native completion — see
/// [`Self::handoff_tab_to_shell`]): the shell's own editor now holds the
/// text, so keys go raw to the PTY exactly as on a shell-vi-mode prompt.
/// Keyed to the entered-prompt *cycle*, not the raw report seq — a
/// same-prompt redraw (completion list, `reset-prompt`) re-emits the
/// PS1-embedded `133;B` and would bump the seq while zle still holds the
/// handed-off text; re-engaging there would fork the two line buffers.
/// Only a command actually running starts a new cycle and re-engages the
/// editor.
editor_handoff: Option<u64>,
/// Active Ctrl+R history search, if any. While set, the editor shows a
/// `(reverse-i-search)` prompt instead of the line and a menu of the ranked
/// matches floats beside it: typing edits the query (fuzzy, blended with
/// frecency), Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts the
/// selection into the line, Cmd+Enter runs it outright, and Escape/Ctrl+G
/// cancels.
reverse_search: Option<ReverseSearch>,
/// One-shot "shell integration didn't engage" notice (#46). Set when Ctrl+R
/// is pressed in a pane whose shell never reported OSC 133 — the history
/// menu the user is reaching for can't appear, and without this the feature
/// just looks broken. A figterm-style PTY shim (kiro-cli-term, qterm) that
/// swallowed the reports is the usual culprit, so the message names the
/// wrapper when the daemon's foreground query recognizes one. Cleared on
/// the next keystroke, after a timeout, or if integration engages late.
integration_notice: Option<String>,
/// Latch so the notice shows at most once per pane — a diagnostic, not a nag.
integration_notice_shown: bool,
/// When this view was created. Ctrl+R inside the startup grace window stays
/// silent: slow rc files mean integration legitimately hasn't reported yet.
created_at: std::time::Instant,
/// True while a left-drag that began on the command-editor line is in progress,
/// so mouse-move extends the editor selection rather than the terminal's.
editor_selecting: bool,
/// True from a left press on the command-editor line until its release —
/// unlike [`Self::editor_selecting`] it also covers the double/triple-click
/// word/line selections, which don't arm drag-extend. Tells the mouse-up
/// that the ended gesture selected in the editor, so copy-on-select copies
/// the editor's selection rather than the terminal's.
editor_select_gesture: bool,
/// When a drag-extend is armed by a double-click, the word range the
/// double-click selected, so the drag can grow the selection by whole words
/// (keeping the anchor word intact). `None` for a plain char-granular drag.
editor_drag_word: Option<(usize, usize)>,
/// Sticky target column for vertical caret motion (↑/↓ across a multi-line
/// buffer). Set on the first vertical step from the caret's current visual
/// column and preserved across a run of ↑/↓ so passing through a short line
/// doesn't lose the column; any other motion or edit clears it (`None`).
editor_goal_col: Option<usize>,
/// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found
/// in the row text), if any. Drives the hover underline and the pointing-hand
/// cursor that mark a link as clickable. Stored in scroll-stable grid
/// coordinates so it survives a scroll without a fresh mouse-move; see
/// [`HoveredLink`].
pub(super) hovered_link: Option<HoveredLink>,
/// Focus listeners kept alive for the lifetime of the view.
_focus_subs: Vec<gpui::Subscription>,
}
/// A link under the mouse, remembered so the grid can underline its cells. The
/// `line` is the alacritty grid line (display row minus the scroll offset), which
/// stays fixed as the viewport scrolls; `start..=end` are the inclusive columns
/// the link's text spans on that line.
#[derive(Clone, PartialEq)]
pub(super) struct HoveredLink {
pub line: i32,
pub start: usize,
pub end: usize,
}
enum LoopbackOpen {
Forwarded(String),
ForwardFailed,
NotLoopback,
}
/// A submitted command whose history-file record is deferred so it can carry
/// the command's exit code (like zsh's `INC_APPEND_HISTORY_TIME`). `seq` is
/// [`RemoteTerminal::prompt_seq`] at submit time: a later report that puts the
/// shell back at its prompt means the run completed and `last_exit_code()` is
/// this command's. Flushed without an exit code if the view goes away first.
struct PendingHistory {
line: String,
cwd: Option<std::path::PathBuf>,
ts: u64,
seq: u64,
}
/// Seconds since the unix epoch — the timestamp history records carry.
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Outcome of a ⌘ shortcut at the terminal surface — the three control-flow
/// paths the key dispatcher needs. Splitting the ⌘ block into its own method
/// keeps `on_key_down` readable; the caller maps each variant back to the
/// stop-propagation / return / fall-through it originally inlined.
enum CmdKey {
/// Handled here — stop propagation and return.
Consumed,
/// Not ours — return without stopping, so the app shell (new tab, split, …)
/// gets it.
Bubble,
/// Recognized but not applicable (e.g. ⌘C with no selection) — fall through
/// to the editor / PTY paths below.
FallThrough,
}
// The minimum foreground-command duration worth a "finished" notification is
// configurable (`Config::notify_threshold_secs`, default 10s); read live where
// the notification is posted rather than pinned to a const here.
/// How long gap input may be held client-side before it must be released to
/// the PTY (see the `hold` module). Long enough for a fast command's full
/// round trip (`133;D` report back to this client — tens of ms), short enough
/// that typing into a program that reads stdin right after launch feels
/// instant once the window lapses.
const HOLD_WINDOW: std::time::Duration = std::time::Duration::from_millis(150);
/// How long after pane creation Ctrl+R stays silent about missing shell
/// integration: slow rc files can take several seconds to reach the first
/// prompt report, and calling integration broken while the shell is still
/// starting up would be a false alarm.
const INTEGRATION_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
/// How long the integration notice stays up when no keystroke dismisses it.
const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Floor on how often an *opportunistic* git probe may run for one cwd (see
/// [`GitRefresh::Opportunistic`]). Short enough that the sidebar's counts feel
/// live while an agent works, long enough that a burst of tool calls — or an
/// alt-tab into a window holding a dozen panes — collapses into one `git`
/// shell-out per repo instead of a dozen.
const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500);
/// Why a git-status probe is being asked for — the two classes get opposite
/// treatment when one is already in flight.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum GitRefresh {
/// A rare state change that must not be missed: the pane changed
/// directory, a command finished, an agent turn ended. Queues behind an
/// in-flight probe (which then reruns) rather than being dropped.
Edge,
/// A cheap signal that repeats on its own: the window regained focus, the
/// agent finished a tool call. Dropped outright when a probe is in flight
/// or one ran within [`OPPORTUNISTIC_GIT_GAP`] — the next one will come.
Opportunistic,
}
/// Fig-descended PTY shims known to exec over the shell we spawned and re-host
/// it on a nested PTY without forwarding OSC 133 — which starves shell
/// integration and silently kills the whole command-editor overlay (#46).
/// Matched against the foreground process name the daemon reports; `contains`
/// because the shim may present as e.g. `zsh (kiro-cli-term)`.
fn known_pty_shim(fg: &str) -> Option<&'static str> {
["kiro-cli-term", "figterm", "qterm", "cwterm"]
.into_iter()
.find(|shim| fg.contains(shim))
}
/// The integration-notice text. Naming the shim matters: "install shell
/// integration" advice would mislead — the hooks *are* installed, something
/// between the shell and tty7 is eating their reports.
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(),
}
}
/// Post a desktop notification that a command finished. Best-effort and
/// non-blocking: routed through [`super::remote::notify_desktop`] (the single
/// `notify-rust` entry point shared with the escape-sequence path), so there's no
/// `osascript` subprocess and every notification goes through one code path.
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);
}
/// Post a branded "the agent finished" notification — the coding-agent form of
/// [`notify_command_finished`], titled with the agent so it's obvious *which*
/// session came back.
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);
}
/// Ring the OS system bell for the `Audible` bell mode. Returns `true` if a
/// sound was actually requested, `false` on platforms without a portable beep
/// (the caller then falls back to the visual flash so the bell is never silent).
fn ring_system_bell() -> bool {
#[cfg(target_os = "macos")]
{
// A parameter-less AppKit call that just asks the system to play the
// user's alert sound; invoked on the main (gpui app) thread, where every
// `AlacEvent` is handled.
objc2_app_kit::NSBeep();
true
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
/// Build the byte sequence written to the PTY for a paste. Under bracketed paste
/// the content is wrapped in the `ESC[200~` / `ESC[201~` markers, and every ESC
/// (`0x1b`) byte is stripped from the content first. Without that strip, clipboard
/// text carrying its own `ESC[201~` end-marker could terminate the paste early and
/// have whatever follows (e.g. a newline + command) run as ordinary typed input —
/// a "bracketed-paste escape" that defeats the very protection the markers give
/// the shell. Removing ESC makes an embedded `ESC[201~` unrepresentable, matching
/// alacritty's own paste filtering. `0x1b` is ASCII, so it never appears inside a
/// multi-byte UTF-8 char — filtering the byte stream can't split a codepoint.
/// Legitimate pasted text does not contain raw ESC, so this is a no-op for it.
///
/// Without bracketed paste, line breaks are normalized to `\r` — the byte the
/// Enter key sends — matching xterm/alacritty. A raw-mode app (the only
/// consumer of this path, since the prompt routes pastes into the editor)
/// reads keys, not lines, and many bind accept/submit to CR only; leaving `\n`
/// in would feed them a byte no keyboard produces.
fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
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()
}
}
/// Build the byte sequence that submits the local editor's buffer to the shell.
///
/// The line count is what matters here. Replaying every embedded newline as its
/// own CR makes the shell's line editor run a *full* prompt cycle per line —
/// preexec, the user's precmd chain (git-status prompts, conda…), a
/// syntax-highlight pass over the whole buffer, plus our own OSC 133 `D`
/// follow-up work. A 30-line paste costs 30 of them and visibly crawls down the
/// screen, as if the command were being retyped. Under bracketed paste the
/// whole buffer goes in as a single paste and one CR accepts it: one prompt
/// cycle whatever the line count.
///
/// Continuation still works — better, in fact. zle keeps the embedded newlines
/// in its buffer, so a backslash / open-quote / heredoc command parses as one
/// unit; the PS2 assembly the per-line replay existed for now happens inside
/// the buffer instead of on the wire. The visible difference is that the block
/// executes as one unit and lands in the shell's history as one entry — which
/// is what pasting multi-line text into any other terminal already does, and
/// what our own history has always recorded.
///
/// ESC is stripped first (in both branches, unlike the paste path): clipboard
/// text carrying its own `ESC[201~` could otherwise close the paste early and
/// have the rest run as typed input, and a raw ESC reaching zle unbracketed is
/// an editor command, not text. CR is normalized away in the same pass, so a
/// CRLF clipboard is one line break either way rather than a stray blank Enter.
///
/// An empty buffer skips the markers: zsh's `bracketed-paste-magic` (which
/// oh-my-zsh turns on) errors on a paste with nothing between them.
///
/// The agent-prompt path already delivers multi-line text this way — see
/// [`crate::core::agent_prompt::submit_bytes`], which is this shape minus the
/// unbracketed fallback (an agent TUI always enables the mode).
fn submit_bytes(line: &str, bracketed: bool) -> Vec<u8> {
// A CRLF clipboard pastes into the editor verbatim, so the `\r` has to go
// before either branch sees it. Unbracketed it would be a second Enter
// (`\r\n` → `\r\r`, a blank line submitted mid-command); bracketed it would
// ride inside the markers and land on whatever the far side happens to do
// with a CR in a paste — zsh turns it into a newline, so the block gains a
// blank line, and a shell that doesn't leaves a literal `^M` in the command.
// One `\n` per line is the shape both branches are written for.
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
}
/// Strip trailing spaces/tabs from every line, preserving the line structure
/// (and any final newline). Used by copy when `clipboard_trim_trailing_spaces`
/// is on so selections don't carry cell-padding whitespace.
fn trim_trailing_spaces(text: &str) -> String {
// `split('\n')` keeps empty segments, so a trailing newline round-trips (the
// final empty segment re-joins into it) and a string without one gains none.
text.split('\n')
.map(|line| line.trim_end_matches([' ', '\t']))
.collect::<Vec<_>>()
.join("\n")
}
/// Backslash-escape the shell-significant characters in a filesystem path so a
/// pasted filename with spaces (or `$`, `'`, `(`, `&`…) reaches the shell as a
/// single argument instead of splitting. Mirrors how macOS Terminal.app turns
/// a dropped/pasted file into command-line text. An empty path
/// becomes `''`.
///
/// A newline/CR can't be backslash-escaped into a literal (`\<newline>` is a
/// shell line-continuation), so a pathological filename containing one is
/// single-quoted whole instead.
fn shell_escape_path(path: &str) -> String {
if path.is_empty() {
return "''".to_string();
}
if path.contains(['\n', '\r']) {
// Close/re-open the single quote around each embedded `'`.
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
}
/// Decide what text a paste should insert for a clipboard item.
///
/// When the clipboard holds file references — a Finder "Copy" carries
/// `ExternalPaths` and (usually) no string rep — we shell-escape each path and
/// join them with a single space, so pasting a file drops a ready-to-use,
/// space-safe path (multiple files → space-separated args), matching macOS
/// Terminal.app. gpui's own `ClipboardItem::text()` would instead
/// concatenate the paths with *no* separator and never escape them.
///
/// Otherwise (plain text, or an image with no text) we defer to `text()`.
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()
}
/// Stage a clipboard image as a temp file so [`paste_clipboard_image`] can paste
/// its path. Web-friendly formats a coding agent's vision accepts (PNG/JPEG/GIF/
/// WebP) are written through untouched; anything else — notably the BMP that
/// Windows screenshots (`CF_DIB`) arrive as — is transcoded to PNG, since agent
/// vision rejects those. Returns the path, or `None` if decoding/writing failed.
///
/// The filename is keyed on gpui's content hash of the bytes, so re-pasting the
/// same screenshot reuses one file instead of accumulating temp copies (this
/// crate has no `Date`/random to mint a unique name with anyway).
#[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)
}
/// Decode `bytes` (in `format`) and re-encode as PNG. SVG can't be rasterized by
/// the `image` crate, so it — and any decode/encode failure — yields `None`.
#[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)
}
/// The font fallback chain: the user's configured list, then this platform's
/// stock faces, then the bundled "Hack" pinned to the end.
///
/// Hack ships inside the binary (`register_bundled_fonts`) and covers the
/// symbols prompt themes lean on — ``, `➜`, box drawing, the sharp powerline
/// wedges — with ink that fits a monospace advance. Without this anchor, a
/// custom `font_family` that lacks one of those codepoints falls through the
/// whole configured list into the OS cascade, which happily serves a
/// proportional glyph wider than the cell that `paint_glyphs`' per-cell clip
/// then truncates (issue #17's severed `➜`).
///
/// Hack carries no CJK at all (1548 codepoints mapped, zero ideographs), so on
/// a chain that names only macOS faces every Chinese character falls through to
/// the OS cascade too. [`platform_last_resort_fallbacks`] is appended for the
/// same reason the Hack anchor exists — to keep the last word ours rather than
/// the cascade's — and it repairs already-persisted configs, which a change to
/// `Config::default` alone would never reach.
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 {
/// The fallible half of a shell-backed view: establish the daemon pane
/// *before* the view is built, so a refused spawn (daemon down, spawn
/// error) comes back as an `Err` the caller can report. Splitting it out
/// matters beyond tidiness: the view is constructed inside `cx.new`, deep
/// under gpui's `extern "C"` input callbacks, where a panic can't unwind
/// and aborts the process instead. Mirrors
/// [`Self::spawn_native_ssh_terminal`].
///
/// Provisional size; corrected on the first prepaint once we can measure.
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
/// re-`attach` to the still-running pane so its process + scrollback come
/// back intact; otherwise `spawn` a fresh pane (with the caller's shell
/// pick, if any). The caller only passes a `restore_pane` it has already
/// confirmed alive, so we trust it here.
pub fn spawn_shell_terminal(
working_directory: Option<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
) -> anyhow::Result<ShellParts> {
let (terminal, pane_id, shell_spec) = match restore_pane {
Some(id) => (
RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?,
id,
// An attached pane keeps whatever shell it already runs; the
// pick that spawned it (if any) isn't persisted.
None,
),
None => {
let (terminal, id) = RemoteTerminal::spawn(
TermSize::new(80, 24),
8,
17,
working_directory,
shell.clone(),
)?;
(terminal, id, shell)
}
};
Ok(ShellParts {
terminal,
pane_id,
shell_spec,
})
}
/// Wrap an established shell pane (from [`Self::spawn_shell_terminal`]) in
/// a view. Infallible by construction — see that function.
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
}
/// Spawn a native (russh) SSH pane for `spec` and build the view around it
/// (PRD FR-C1/E-series). The caller (`ui::ssh_connect`) has already resolved
/// keychain secrets into `spec`; this view retains only the **secret-free**
/// copy ([`NativeSshSpec::without_secrets`]) for session-restore respawn and
/// the in-pane reconnect. Auth/host-key prompts and the connection phase ride
/// this pane's own stream and surface through the usual `AuthPromptReady`
/// path.
/// The fallible half of a native-SSH view: establish the daemon pane first,
/// so a refused spawn (daemon down, stale pre-SSH daemon, protocol error)
/// surfaces as an `Err` the caller can report — building the view itself
/// (inside `cx.new`, via [`Self::from_native_ssh_parts`]) has no failure
/// path of its own.
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,
})
}
/// Wrap an established native-SSH pane (from
/// [`Self::spawn_native_ssh_terminal`]) in a view.
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
}
/// Build the view around an already-connected terminal. Split from [`new`]
/// so tests can hand in a `RemoteTerminal` backed by a plain socketpair
/// and exercise the event plumbing without a live daemon.
fn with_terminal(
terminal: RemoteTerminal,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Font comes from user config: a primary face plus fallbacks so glyphs it
// lacks still render (e.g. a Nerd Font supplies powerline / box separators
// and an emoji face covers pictographs). Defaults are Menlo + Hasklug Nerd
// Font Mono + Apple Color Emoji at 13px.
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.clone();
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();
}
// Optional distinct bold/italic faces, each carrying the same fallback
// chain so glyph coverage matches the primary face.
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();
// Pump backend events → redraws. The reader thread sends one Wakeup per
// output chunk, and a TUI redrawing at full tilt (Claude Code streaming)
// produces long bursts of them; drain whatever queued up behind the
// first event and collapse the Wakeups to one, so a burst costs one
// update+notify instead of scheduling dozens of no-op round-trips
// between two frames.
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(..) {
// A Wakeup only marks the view dirty, so one per batch
// is enough; order relative to other events is moot.
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,
};
// `notify()` above only dirties windows whose tracked-entity set
// still contains this view; if one frame drops the view from
// that set, every later notify is filtered, the window never
// goes dirty, never redraws, and so never re-tracks the view —
// grid updates then sit unseen until some input event forces a
// refresh. Dirty the view's current window directly so PTY
// output always reaches the screen; painting stays vsync-paced,
// so a batch costs the same one frame either way. Failure here
// only means no window right now — never tear down the pump.
if woke {
let _ = this.update_in(cx, |_, window, _| window.refresh());
}
}
})
.detach();
// Track focus so the cursor blinks only while focused, resetting the
// blink phase on focus changes so it's solid the instant focus returns.
// Focus changes are also reported to the app when it asked for them
// (mode 1004): vim's autoread, tmux's focus hooks and prompt
// frameworks' cursor dimming all key off `CSI I`/`CSI O`.
let focus_subs = vec![
cx.on_focus_in(&focus_handle, window, |view, _window, cx| {
view.focused = true;
view.cursor_visible = true;
// Looking at the pane marks its finished turn read, so the tab
// avatar's green Done dot clears — unless this focus-in is
// just the context menu handing focus back after a manual
// "Mark as Unread" (the one-shot guard eats it).
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();
}),
];
// Blink the block cursor. Toggling and the redraw happen only while
// focused; unfocused we draw a static hollow box and skip the work.
// The task stops naturally once the view is dropped (update → Err).
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(530))
.await;
if this
.update(cx, |view, cx| {
// The search field blinks its own caret; here we only drive
// the terminal's block cursor.
if view.focused {
// Honor `cursor_blink`: when off, keep the cursor
// solid (force it visible if a prior toggle left it
// hidden) instead of flipping it.
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();
// Poll the PTY's foreground process group once a second to notice when a
// long-running command finishes while the window is in the background,
// and post a desktop notification. `update_in` gives us the Window so we
// can check whether it's currently active.
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);
// Rank without a directory bias for now; the first `poll_foreground` learns
// the cwd and re-ranks (favouring commands run in this directory).
let history = super::history::load();
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,
pane_id,
shell_spec: None,
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,
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,
ranked_cwd: None,
history_nav: None,
history_stash: String::new(),
pending_history: None,
completion: None,
completion_generation: 0,
editor_handoff: None,
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,
}
}
/// Called from the element each frame with the measured grid geometry.
pub fn set_grid_size(
&mut self,
cols: usize,
rows: usize,
cell_width: Pixels,
line_height: Pixels,
) {
// A remembered hover cell describes the *old* geometry: after a resize
// its row may not exist any more, and the pointer sits over a different
// cell regardless. Forget it — the next mouse move records a fresh one.
// (`grid_line` also refuses a stale row, so this is about not underlining
// the wrong cell, not about safety.) The link that cell resolved to goes
// with it: it is held in grid coordinates the reflow just moved text
// under, so keeping it would underline whatever now sits there (and hold
// the pointing-hand cursor over it) until the pointer moves again.
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;
self.terminal.resize(
TermSize::new(cols, rows),
cell_width.as_f32().round() as u16,
line_height.as_f32().round() as u16,
);
}
/// Current working directory of this terminal's foreground process, used so
/// new tabs / splits can open in the same place. `None` if it can't be read.
pub fn cwd(&self) -> Option<std::path::PathBuf> {
self.terminal.foreground_cwd()
}
pub fn remote_context(&self) -> Option<RemoteContext> {
self.terminal.remote_context()
}
/// The pane's cwd *only when it names a directory on this machine* — the
/// accessor every local filesystem or `Command` use must go through.
///
/// A remote pane's OSC 7 reports a path in the remote's namespace
/// (`/home/me/proj` from an SSH host). Feeding that to a local `git` or
/// `read_dir` is meaningless, and on Windows it is worse than meaningless:
/// `/home/me/proj` is not an absolute path there but a *drive-relative*
/// one, so it silently resolves to `C:\home\me\proj`. That usually just
/// fails an `exists()` check — but if such a directory happens to exist,
/// the pane reports an unrelated local repo's branch and diff as its own.
/// Correctness must not rest on that collision never happening.
///
/// Note this gates on the pane being remote, not on the shape of the path:
/// a local shell may legitimately sit in a directory whose name looks
/// remote, and Git Bash reports genuinely local paths (via `pwd -W`) that
/// merely originate from a POSIX-looking shell.
pub fn local_cwd(&self) -> Option<std::path::PathBuf> {
self.remote_context().is_none().then(|| self.cwd())?
}
/// The coding agent running in this pane's foreground, or `None` when none
/// is. Identity comes from the daemon's foreground-`argv` detection (plus
/// the sentinel event channel, which can brand wrappers argv can't see
/// through). The tab avatar brands the pane with it. See
/// [`crate::core::cli_agent`].
pub fn agent(&self) -> Option<crate::core::cli_agent::CLIAgent> {
self.terminal.foreground_agent()
}
/// The agent's rich session status (idle / working / waiting / done +
/// native session id), when the pane's agent reports events over the
/// sentinel OSC channel (or the opaque notification fallback). Drives the
/// avatar's status dot, "needs your input" notifications, and resume. An
/// output-idle *guess* is deliberately still absent — agents are quietest
/// while thinking, so only agent-reported state is trusted.
pub fn agent_session(&self) -> Option<crate::core::cli_agent::AgentSessionState> {
self.terminal.agent_session()
}
/// Whether this pane's finished turn (the green `Done` dot) is unread — a
/// turn ended that the user hasn't looked at since (see
/// [`agent_result_unread`](Self::agent_result_unread) field). Feeds the
/// tab's unread count (the avatar dot's count badge); the dot itself shows
/// for any `Done`.
pub fn agent_result_unread(&self) -> bool {
self.agent_result_unread
}
/// Re-flag this pane's finished turn as unread — the tab context menu's
/// "Mark as Unread". `refocus_incoming` is true for the pane the dismissed
/// menu is about to hand window focus back to (the active tab's focused
/// leaf): that focus-in is the menu closing, not the user reading the
/// result, so it must not clear the mark it just made.
pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) {
self.agent_result_unread = true;
self.keep_unread_on_focus = refocus_incoming;
}
/// The git snapshot for this pane's cwd (branch + working-tree diff), for
/// the sidebar row's branch line — read from the shared per-repo
/// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), so
/// every pane in one work tree reports the same numbers. `None` outside a
/// git work tree or before the repo's first background probe lands.
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(cwd)
}
/// The cwd the pane's git line reads from — the same path [`git_status`]
/// resolves through, so the diff overlay opened from that line probes the
/// identical repo (not a fresh foreground-cwd syscall that could disagree
/// mid-command). `None` outside a repo-probe-worthy state.
///
/// [`git_status`]: Self::git_status
pub fn git_status_cwd(&self) -> Option<&std::path::Path> {
self.git_status_cwd.as_deref()
}
/// Re-probe this pane's git status opportunistically — for callers holding
/// a reason to suspect the tree moved without the pane seeing it. The one
/// that matters is the window regaining focus: edits made in an editor, or
/// by a `git` command run in another app entirely, produce no event here at
/// all, so without this the counts would sit stale until the user happened
/// to run something in the pane.
///
/// Throttled and in-flight-deduped (see [`GitRefresh::Opportunistic`]), so
/// calling it for every pane on every activation is cheap. A pane with no
/// resolved cwd yet is skipped rather than being pinned to `None` — its
/// first real probe is the poll loop's job.
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);
}
}
/// The current grid selection as text, if any non-blank one exists — the
/// source for "Agent: Send Selection".
pub fn selection_text(&self) -> Option<String> {
self.terminal
.term
.lock()
.selection_to_string()
.filter(|t| !t.trim().is_empty())
}
/// Deliver a built prompt into this pane's PTY as a bracketed paste + CR —
/// the submit path for the agent context-feed commands. See
/// [`crate::core::agent_prompt::submit_bytes`].
pub fn send_agent_prompt(&self, prompt: &str) {
self.terminal
.write(crate::core::agent_prompt::submit_bytes(prompt));
}
/// Type one command line + Enter into the pane's PTY, as if the user had.
/// Used by session restore to hand a fresh shell an agent resume command;
/// the bytes queue in the PTY until the (possibly still-starting) shell
/// reads them.
pub fn run_command_line(&self, cmd: &str) {
self.terminal.write(format!("{cmd}\r").into_bytes());
}
/// The shell this pane was explicitly spawned with (new-tab dropdown pick),
/// so splits can inherit it. `None` → the default shell.
pub fn shell_spec(&self) -> Option<ShellSpec> {
self.shell_spec.clone()
}
/// The secret-free native-SSH spec this pane ran, if it is a native-SSH pane.
/// Persisted for session restore and re-used by the in-pane reconnect
/// (`RestartSshSession`).
pub fn ssh_spec(&self) -> Option<Box<crate::daemon::protocol::NativeSshSpec>> {
self.ssh_spec.clone()
}
/// The native-SSH connection phase for the status strip (PRD FR-E1); `None`
/// for a non-native pane.
pub fn ssh_phase(&self) -> Option<crate::daemon::protocol::SshPhase> {
self.terminal.ssh_phase()
}
/// Whether this native-SSH pane's connection is dead (shell exited or the
/// connect failed) and so eligible for an in-pane reconnect. False for live
/// panes and non-native panes.
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>) {
// Surface a child-exit/daemon-disconnect noticed by the reader thread into
// the field the view reads directly (`self.terminal.exited`).
self.terminal.poll_exited();
// A native-SSH pane may have queued an auth/host-key prompt behind this
// wakeup; let the app drain it into the in-pane sheet. Cheap check —
// only true during the brief pre-Output auth window.
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 = "tty7 — process exited".to_string();
// A genuine child exit closes the pane (the app subscribes and
// collapses the split / closes the tab). A daemon disconnect
// reaches this same arm but must NOT auto-close: the session
// may still be alive daemon-side, and closing would both hide
// the failure and kill the pane.
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) => {
// OSC 10/11/12 query the default foreground/background/cursor as
// the special indices 256/257/258, which live *outside* the
// 256-color palette. The old `idx.min(255)` clamped them all to
// palette[255] (near-white), so apps probing the background to
// pick a light/dark UI (e.g. Claude Code) saw a "light" terminal
// and switched to a washed-out light theme. Reply with the real
// theme colors instead.
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 {
// Silenced: neither flash nor sound.
BellMode::None => {}
// Visual bell: a brief flash instead of an audible beep.
BellMode::Visual => self.flash_bell(cx),
// Audible bell: ring the system bell. Where none exists (non-mac
// today), fall back to the flash so an opted-in bell is never
// silent.
BellMode::Audible => {
if !ring_system_bell() {
self.flash_bell(cx);
}
}
},
AlacEvent::TextAreaSizeRequest(fmt) => {
// CSI 14 t: the text area size in pixels. Image-preview TUIs
// (yazi, ranger's chafa/sixel backends) size their graphics
// from this reply; ignoring the request leaves them guessing
// or stalling on a report that never comes.
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());
}
_ => {}
}
}
/// Report a focus change to the application (`CSI I` / `CSI O`) when it
/// opted into focus events (mode 1004). No-op otherwise.
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>) {
if self.terminal.exited {
return;
}
// Any keystroke dismisses a visible integration notice — it has been
// read. The Ctrl+R that raises it runs later in this same dispatch, so
// the raising chord never clears its own notice.
if self.integration_notice.take().is_some() {
cx.notify();
}
// macOS Option-key policy (see `input::reshape_option_keystroke`):
// reshape the chord once, up front, so every consumer below — the ⌘
// dispatcher, the prompt editor, the raw PTY encoder — sees the same
// story. Other platforms have no composed-character split to resolve.
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;
// While the search field is focused it owns the keyboard — typing, caret
// movement, selection, Cmd+A and IME are all handled inside the field, and
// Enter is delivered via its `PressEnter` event. We only intercept Escape
// to close the bar; any other key that bubbled up here was unhandled, so
// swallow it rather than leak it to the PTY.
if self.search.is_some() && self.search_focused {
if ks.key == "escape" {
self.close_search(window, cx);
cx.stop_propagation();
}
return;
}
// Cmd shortcuts (copy / paste / find / select-all + macOS line editing).
// Delegated to keep this dispatcher scannable; the outcome decides whether
// we consume the key, let it bubble to the app shell (new tab / split /
// switch), or fall through to the editor / PTY paths below.
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 => {}
}
}
// Off macOS there is no reachable Cmd key, so the clipboard trio lives on
// Ctrl (the Windows/Linux convention). Route only Ctrl+C / Ctrl+V / Ctrl+X
// to the shared clipboard handler; every other Ctrl chord keeps its shell /
// readline meaning (Ctrl+Z suspend, Ctrl+R reverse-search, Ctrl+F forward,
// …). Ctrl+C copies an active selection and otherwise falls through to ^C
// (SIGINT); Ctrl+X cuts a prompt selection; Ctrl+V pastes.
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 => {}
}
}
// Off macOS the "secondary" modifier is Ctrl, so Ctrl+1..9 switches tabs at
// the app shell (mirroring macOS's Cmd+1..9, which bubbles via the platform
// branch above). Those digit chords have no terminal meaning, so return
// without consuming the event — letting it bubble to the root `on_key_down`
// handler — instead of being swallowed by the editor / PTY paths below.
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;
}
// On macOS all ordinary text goes out through the IME, never through
// `key_char` — see `input::defer_to_ime` for why (gpui reconstructs
// `key_char` from the virtual keycode, which is a lie for synthesized
// events). Decline the key without consuming it and gpui hands the
// native event to the input context, which delivers the real text via
// `commit_text`.
//
// Kitty's REPORT_ALL_KEYS_AS_ESC is the exception — `defer_to_ime`
// declines under it so the key reaches the encoder below.
//
// A pending multi-key chord is already handled before this point: a key
// that completes a sequence is dispatched as an action and never
// reaches `on_key_down`. The check below is belt-and-braces (gpui takes
// `pending_input` earlier in `dispatch_key_event`, so it never fires
// here) and mirrors `prefers_ime_for_printable_keys`, which *is* live.
#[cfg(target_os = "macos")]
if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) {
return;
}
// While idle at the prompt, our local command editor owns the keyboard:
// editing keys act on the in-memory line and Enter ships it to the PTY.
// Printable text is delivered through the IME path (`commit_text`), so we
// only handle the non-text keys here and consume everything else (so it
// never leaks to the PTY as a raw byte).
if self.input_active() {
self.handle_editor_key(ks, cx);
cx.stop_propagation();
return;
}
// Ctrl+R reaching this raw path means the tty7 history menu the user is
// probably reaching for cannot appear here. When that's because shell
// integration never engaged — not because a foreground command owns the
// PTY — say so once instead of failing silently (#46). The chord still
// goes to the PTY below, so the shell's own reverse-i-search keeps
// working as the fallback.
// Nothing to explain when the user switched the menu off — Ctrl+R
// reaching the PTY is then exactly what they asked for.
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();
// A plain Backspace is reconstructable gap input: offer it to the
// hold, so a fast command's typeahead never touches the PTY (see
// `hold`). Anything else releases the hold first — FIFO order on
// the wire — and goes raw, kept in step with the typeahead record
// for the deferred wipe.
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(),
);
}
}
// Keep the cursor solid while typing (resets the blink phase).
self.cursor_visible = true;
// Typing clears the selection and jumps to the prompt.
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
cx.notify();
// Consume so the key isn't also re-sent through the IME path.
cx.stop_propagation();
}
}
/// Handle a ⌘ shortcut at the terminal surface and report what the dispatcher
/// should do with the key (see [`CmdKey`]). Covers copy / cut / paste / find /
/// select-all plus the macOS editor line-editing chords (⌘Z, ⌘←/→, ⌘⌫), all of
/// which only act at the prompt. Behavior is identical to the inline block it
/// replaced; only the stop-propagation / return plumbing moved to the caller.
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() {
// Copy / cut / paste route through the same methods the `CopyText` /
// `CutText` / `PasteText` actions call, so the chord, the right-click
// row and the Edit menu can't drift apart.
"c" => {
// `clear_on_copy`: Ctrl+C is dual-purpose — copy with a
// selection, ^C (SIGINT) without — so the copy must consume the
// selection or the next press copies again instead of
// interrupting (#111). ⌘C never doubles as SIGINT, so there the
// selection stays highlighted (the macOS convention).
if self.copy_contextual(m.control, cx) {
CmdKey::Consumed
} else {
// Nothing was selected anywhere: don't swallow the key, so
// Ctrl+C still reaches the PTY as ^C.
CmdKey::FallThrough
}
}
"x" => {
// Cut is editor-only; outside the prompt there is nothing to
// remove, so the key falls through rather than dying silently.
if self.cut_contextual(cx) {
CmdKey::Consumed
} else {
CmdKey::FallThrough
}
}
"v" => {
self.paste_from_clipboard(cx);
CmdKey::Consumed
}
// Find (open bar) and ⌘G / ⌘⇧G (next / previous match) are registered
// keybindings — `FindInTerminal` / `FindNext` / `FindPrevious` — so they
// are visible and rebindable in Settings and get a working default on
// every platform (⌘F on macOS, Ctrl+Shift+F elsewhere). They dispatch
// through `on_action`, not this inline path.
"a" => {
// At the prompt, ⌘A selects the whole edited line; otherwise it
// selects the whole terminal buffer (scrollback included).
self.select_all_contextual(cx);
CmdKey::Consumed
}
// The following are editor-only (macOS line editing); they're swallowed
// elsewhere since they have no terminal meaning.
"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" => {
// ⌘⌫ deletes to line start; ⌘⌦ is its mirror — delete to line end.
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,
}
}
/// Handle one keystroke while the local command editor is live at the prompt.
/// Editing keys and readline-style control combos act on `self.cmd`; Enter
/// submits; ↑/↓ recall history. Printable text is *not* handled here — it
/// arrives via the IME path (`commit_text`). Tab is claimed by the `SendTab`
/// action (reserved for completion), so it never reaches this method.
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;
// Any key other than a vertical step drops the sticky goal column, so the
// next ↑/↓ takes its column from wherever the caret ends up.
if key != "up" && key != "down" {
self.editor_goal_col = None;
}
// A reverse search, when active, owns the keyboard.
if self.reverse_search.is_some() {
self.handle_reverse_search_key(ks, cx);
return;
}
// ⌃J / ⌃M are readline's accept-line — the terminal's own encoding of
// Enter (LF / CR), and what most shells' default keymaps bind. Route
// them through the same path Enter takes, completion picker included.
// Left alone they reach `apply_readline_ctrl`'s no-op arm and the Ctrl
// branch below swallows them, so the key does nothing at all (#163).
if m.control && !m.platform && !m.alt && matches!(key, "j" | "m") {
self.accept_line(cx);
return;
}
// While a completion menu is open it behaves as a picker:
// ↑/↓ move the highlight, Enter writes the highlighted candidate into
// the line (a second Enter submits; Cmd+Enter does both in one stroke),
// Escape just closes — the line keeps any filled prefix. Tab/Shift-Tab
// (via the SendTab action) fill the common prefix / move the highlight.
// Typing and Backspace re-filter the menu live; any other key falls
// through and closes it just below.
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") => {
// Cmd+Enter: accept the highlighted candidate and run it.
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;
}
_ => {}
}
}
// Any other editing key closes an open completion menu.
self.close_completion();
// Readline-style control combinations, delegated so this dispatcher stays
// scannable. Every Ctrl chord is swallowed at the prompt (recognized or
// not), so this always notifies and returns.
if m.control && !m.platform && !m.alt {
// Off macOS, word navigation and deletion live on Ctrl (the Windows /
// Linux convention): Ctrl+←/→ move by word (Shift extends the
// selection), Ctrl+⌫/⌦ delete a word. macOS keeps these on Alt (handled
// below) — its Ctrl+arrows are OS-level Space switches, and Ctrl+letters
// stay readline — so claim the arrow / delete keys only off macOS.
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;
}
_ => {}
}
}
// Off macOS, Ctrl is the primary modifier, so Ctrl+A is expected to
// select the whole edited line (text-editor / Windows convention) —
// there is no reachable Cmd key to carry the macOS `Cmd+A`. macOS keeps
// the readline `Ctrl+A` = move-to-line-start (its select-all is Cmd+A).
if cfg!(not(target_os = "macos")) && key == "a" {
self.cmd.select_all();
self.close_completion();
self.cursor_visible = true;
cx.notify();
return;
}
// With the history menu switched off ⌃R belongs to the shell: hand
// the line over and let whatever is bound there answer — zle /
// readline's own reverse-i-search, or an fzf / percol widget (#163).
if key == "r" && !cx.global::<Config>().history_search {
self.handoff_line_to_shell(&[0x12], cx);
return;
}
self.apply_readline_ctrl(key);
cx.notify();
return;
}
// Readline-style Meta word chords on the edited line: M-b / M-f motions
// and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
// macOS these are reachable only with `macos_option_as_alt` on — with it
// off the chord composes a character upstream and arrives here altless,
// through the printable-text arm. Other Alt+letter chords stay swallowed
// no-ops as before (the local editor can't mirror every zle widget).
if m.alt && !m.platform && !m.control {
match key {
"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" => {
// Shift+Enter / Opt+Enter inserts a newline to author (or extend)
// a multi-line command; a plain Enter submits the whole buffer.
if (m.shift || m.alt) && !m.control && !m.platform {
self.cmd.insert_str("\n");
self.history_nav = None;
cx.notify();
return;
}
self.submit_command(cx);
return;
}
"backspace" => {
// Empty editor: nothing local to delete, but the shell's own
// line may hold type-ahead the editor never saw (bytes that
// reached the PTY outside it — e.g. typed into a finishing
// command). Pass the key through so such strays are always
// erasable by hand; on a truly empty line it's a shell no-op.
// An undrained record must mirror the erase (editor active ⇒
// primary screen, so no alt-screen taint applies).
if self.cmd.is_empty() {
self.terminal.write(vec![0x7f]);
self.typeahead.observe(
RawInput::Key {
key: "backspace",
plain: true,
},
false,
);
return;
}
// backspace() deletes the selection if there is one; only fall
// back to word-delete when nothing is selected.
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" => {
// At end-of-line with a suggestion and no selection, → accepts it.
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" => {
// Within a multi-line buffer ↑ moves up a visual row; from the
// top row it recalls the previous history entry.
if self.editor_move_v(false, m.shift) {
cx.notify();
} else {
self.history_prev(cx);
}
return;
}
"down" => {
// The mirror of ↑: down a visual row, or newer history from the
// bottom row.
if self.editor_move_v(true, m.shift) {
cx.notify();
} else {
self.history_next(cx);
}
return;
}
"escape" => {
// Esc carries no local-editor meaning, so pass it straight to the
// shell — its own zle/readline bindings act on it (vi command
// mode from bindkey/readline vi mode, `\e`-prefixed widgets,
// menu-select cancel). Shell vi-mode itself disables the local
// editor from prompt start, so this is only the emacs-mode
// fallback path.
let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
.unwrap_or_else(|| vec![0x1b]);
self.terminal.write(bytes);
return;
}
// Printable text delivered directly, without an IME round-trip. On
// macOS printable keys are routed to the IME and arrive via
// `commit_text`, so they never reach this method. On Linux (where
// `prefers_ime_for_printable_keys` is false because gpui's IBus path
// doesn't commit plain ASCII back) they arrive here as ordinary key
// events carrying `key_char`; feed them through the same commit path
// the IME would use so the local editor sees the text. Skip control /
// Cmd chords and any non-printable char (function keys have no
// `key_char`; Alt combos stay editor no-ops as before).
_ => {
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;
}
}
}
}
}
cx.notify();
}
/// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F
/// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions
/// (each removing the selection first if there is one), Ctrl-L clear-screen,
/// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete.
/// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at
/// the prompt regardless).
///
/// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history
/// menu is switched off, Ctrl-R before this point — neither reaches here.
fn apply_readline_ctrl(&mut self, key: &str) {
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" => {
// Accept the autosuggestion if one is showing; else move right.
if let Some(full) = self.ghost_suggestion() {
self.cmd.set(&full);
} else {
self.cmd.clear_selection();
self.cmd.move_right();
}
}
// Deletion combos remove the selection first if there is one.
"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(),
"l" => {
// Clear screen belongs to the shell/readline layer: send the
// same form-feed byte the raw terminal path emits for Ctrl+L.
self.terminal.write(vec![0x0c]);
}
"c" => {
// Interrupt: drop the edited line and let the shell draw a
// fresh prompt (send ^C, as a real terminal would). zle's own
// ^C aborts its line, unadopted gap strays included — the
// typeahead record is moot and must not resurrect them at the
// next prompt; likewise any still-held gap input is discarded
// (^C means "throw the line away").
self.cmd.clear();
self.history_nav = None;
let _ = self.typeahead.drain();
let _ = self.hold.engage();
self.terminal.write(vec![0x03]);
}
"d" => {
// ^D on an empty line is EOF (exits the shell); otherwise it's
// a forward-delete. EOF only reads as EOF on an *empty* zle
// line — unadopted gap strays would turn it into a completion
// listing, so wipe them first.
if self.cmd.is_empty() {
self.wipe_pending_typeahead();
self.terminal.write(vec![0x04]);
} else {
self.cmd.delete();
}
}
_ => {}
}
}
/// Horizontal caret motion in the editor with selection semantics: Shift
/// extends, a plain move with an active selection collapses to its edge,
/// otherwise the caret moves (by word when `word`).
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(),
}
}
/// Home/End motion with selection semantics (Shift extends, else collapses).
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();
}
}
/// Vertical caret motion across a multi-line / wrapped input buffer (↑/↓),
/// with a sticky goal column so passing through a short line keeps the
/// target column. Returns `true` if the caret moved within the buffer;
/// `false` means it was already on the top row (↑) or bottom row (↓), so the
/// caller falls through to history recall — matching how fish/zsh edit a
/// multi-line line. Shift extends the selection.
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);
// The caret renders on the cell of the char it sits before, or on a
// trailing slot at the buffer end (a fresh row when the buffer ends in a
// newline).
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;
}
// On the boundary row in the travel direction, defer to history recall.
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);
// Land on the caret slot of the target row nearest the goal column. Char
// `i`'s slot is the caret *before* it; the buffer-end slot is `len`.
let mut best: Option<(usize, usize)> = None; // (index, |col - goal|)
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()
}
/// Is there anything [`copy_contextual`](Self::copy_contextual) would copy —
/// in the grid *or* in the prompt editor? What the Copy / Cut menu rows gate
/// on: `has_selection` alone is grid-only, so a prompt selection used to
/// leave "Copy" greyed out even though ⌘C would have copied it.
fn any_selection(&self) -> bool {
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
}
/// Snapshot the Kitty keyboard-protocol flags the app has enabled, read off the
/// local `Term`'s mode bits (the reader thread keeps them current by advancing
/// the emulator over all child output). Consulted by the key encoder so TUIs
/// that opt into the protocol get `CSI u` reports.
pub(super) fn kitty_flags(&self) -> super::input::KittyFlags {
super::input::KittyFlags::from_mode(self.terminal.term.lock().mode())
}
/// Bytes for a Tab / Shift-Tab press sent to the PTY. Honors the Kitty keyboard
/// protocol when a full-screen app enabled it (so `Tab` arrives as `CSI 9 u`,
/// distinct from `Ctrl+I`); otherwise the legacy HT / back-tab sequences. These
/// keys reach the PTY through the `SendTab`/`SendBackTab` actions rather than
/// `on_key_down`, so the Kitty encoding is applied here as well.
fn tab_bytes(&self, shift: bool) -> Vec<u8> {
super::input::tab_bytes(shift, self.kitty_flags())
}
/// Write a fixed byte sequence to the PTY (for keystrokes delivered as
/// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying
/// the same cursor / selection / scroll housekeeping as normal typing.
fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
if self.terminal.exited {
return;
}
self.terminal.write(bytes.to_vec());
self.cursor_visible = true;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
cx.notify();
}
/// Select the entire buffer — from the top of scrollback to the last cell —
/// so Cmd+A then Cmd+C copies everything.
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();
}
/// "Select All" as the user means it in context: at the prompt, select the
/// edited command line; otherwise select the whole terminal buffer. Shared by
/// the ⌘A shortcut and the right-click "Select All" item so the two never
/// drift apart.
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);
}
}
/// Paste clipboard text. While idle at the prompt it goes into the local
/// command editor (a single trailing newline is dropped so a copied line
/// doesn't auto-submit). Otherwise it's written to the PTY, wrapped in
/// bracketed-paste markers when the app enabled that mode (so shells/editors
/// treat it as one paste rather than typed-and-executed input).
pub fn paste(&mut self, text: String, cx: &mut Context<Self>) {
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;
}
// A gap paste rides the same hold as typed text (a clean single-line
// paste ahead of a fast command lands in the editor, PTY untouched);
// `write_gap_text` taints the record on embedded newlines — those
// lines execute as commands zle-side and must not become a seed.
let bracketed = self
.terminal
.term
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
// `paste_bytes` wraps in bracketed markers when the app enabled that
// mode (the receiver's own guard against a pasted command
// auto-executing) and strips any ESC so clipboard text can't smuggle
// its own `ESC[201~` end-marker to break out.
self.write_gap_text(&text, paste_bytes(&text, bracketed), cx);
// Pasting to the PTY is input like typing: it consumes the selection,
// so a following Ctrl+C means ^C again (#111). The editor branch above
// leaves the selection alone, matching `commit_text`.
self.terminal.term.lock().selection = None;
cx.notify();
}
// ---- Mouse tracking (so vim / tmux / zellij get clicks & drags) ----
/// True when the application has enabled any mouse-reporting mode.
/// Drive the momentary visual bell flash: turn it on now, then schedule a
/// one-shot task to clear it ~150ms later. Shared by the `Visual` bell mode
/// and the `Audible` fallback on platforms without a system bell.
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)
}
/// Encode and send a single mouse event to the PTY. `base` is the raw button
/// code (0/1/2 buttons, 64/65 wheel, 32/33/34 drag-motion); `row`/`col` are
/// 0-based viewport coordinates.
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) {
// Only report when the cell changed, and only if the app asked for drag
// or motion tracking.
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);
}
/// Report button-less mouse motion when the app asked for *all* motion
/// (mode 1003, any-event tracking) — hover-driven TUIs never see the mouse
/// otherwise. Drags (a button held) go through [`mouse_drag`] instead.
/// Deduped per cell like drags, so pixel moves within one cell don't spam
/// the PTY. Base 35 = the motion flag (32) plus "no button" (3).
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);
}
/// Scroll handling that also honors mouse-wheel reporting and alternate
/// scroll, falling back to local scrollback otherwise.
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();
// "Mouse reporting off" also silences the wheel: drop the report mode so
// the tick falls through to alternate-scroll / local scrollback, exactly
// as if the app had never asked for wheel reporting.
if !self.report_mouse {
mode.remove(TermMode::MOUSE_MODE);
}
match wheel_route(mode, mods.shift, lines > 0) {
// Mouse-wheel reporting: one report per line, at the last mouse cell.
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);
}
}
// Alternate scroll: translate the wheel into arrow keys for
// full-screen apps (less, man) that don't do mouse reporting.
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);
}
// Local scrollback, in whole lines (wheel scrolling goes through
// `smooth_scroll` instead and keeps a sub-line fraction; a
// line-quantized jump here must not leave a stale fraction shifting
// the paint).
WheelRoute::Scrollback => {
self.scroll_frac = 0.;
self.terminal
.term
.lock()
.scroll_display(Scroll::Delta(lines));
cx.notify();
}
}
}
// ---- Cmd+F search ----
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 {
// Optionally strip trailing whitespace from each line — a block/rect
// selection or wrapped rows otherwise carry padding spaces.
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));
}
}
}
/// Copy whatever is selected, preferring the prompt editor's selection over
/// the terminal grid's. Returns whether anything was actually copied — the
/// ⌃C path needs to know, because with nothing selected the key has to fall
/// through to ^C (SIGINT).
///
/// `clear_on_copy` drops the selection after copying. Ctrl+C is dual-purpose
/// (copy with a selection, SIGINT without), so it must consume the selection
/// or the next press copies forever instead of interrupting (#111); ⌘C and
/// the menu items leave the highlight up, the macOS convention.
///
/// The single copy path: ⌘C / ⌃C, the right-click "Copy" row, and the Edit
/// menu all land here.
pub fn copy_contextual(&mut self, clear_on_copy: bool, cx: &mut Context<Self>) -> bool {
// At the prompt the editor's selection wins — but only when it has one.
// With no editor selection we fall on through: the user may have
// mouse-selected terminal output/scrollback, which lives in
// `term.selection`, not in the editor.
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
}
/// Step to the next (`forward`) or previous search match. A no-op while the
/// find bar is closed — there is nothing to step through. Exposed for the
/// palette's "Find Next" / "Find Previous", which run from outside the
/// terminal module and so can't reach `step_match` directly.
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);
}
/// Undo (or, with `redo`, redo) the last prompt edit. Editor-only: the
/// terminal grid has no edit history, so outside the prompt this is a no-op
/// that still swallows the gesture rather than sending ⌘Z to the PTY.
/// Shared by the ⌘Z chord and the Edit menu's Undo / Redo.
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();
}
/// Cut the prompt editor's selection: copy it out, then delete it. Only
/// meaningful at the prompt — the terminal grid is not editable — so this
/// reports whether the gesture was *handled* (i.e. the prompt was active),
/// not whether text was actually removed; a cut with nothing selected is
/// still a no-op the prompt owns rather than a key the PTY should see.
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
}
/// Read the system clipboard and paste it into the PTY (bracketed-paste
/// aware). The single paste path: ⌘V / ⌃V, the right-click "Paste" row, and
/// the Edit menu.
///
/// Text wins when the clipboard carries any. Failing that — an image-only
/// clipboard (a screenshot) dropped on a pane whose foreground app is a TUI
/// coding agent — the image is written to a temp file and its path typed in,
/// which is how those agents take attachments.
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);
}
}
/// Files dragged in from Finder (etc.) and dropped on the terminal:
/// shell-escape each path, join with spaces, and insert them like a paste —
/// with a trailing space so a dropped path is ready to be an argument and
/// back-to-back drops don't run together. Matches macOS Terminal.app
/// (which reuses its paste escaping for drops).
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);
}
/// Paste a clipboard image (e.g. a screenshot) into a foreground coding-agent
/// TUI. Agents like Claude Code attach an image typed as a *file path* at the
/// prompt — the same route drag-and-drop uses — so off macOS we stage the image
/// to a temp file and paste its shell-escaped path, mirroring [`drop_files`].
///
/// On macOS the agent can instead read the image straight from the pasteboard
/// when it sees Ctrl+V, so we forward SYN (`0x16`) and let it do that
/// higher-fidelity read. That same read is unreliable off macOS — Claude Code on
/// Windows silently drops raw screenshots (anthropics/claude-code#26679) — which
/// is why we materialize a file there. If staging fails, we fall back to SYN.
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]);
// PTY input consumes the selection, like `paste` (#111).
self.terminal.term.lock().selection = None;
cx.notify();
}
/// Clear the terminal (right-click "Clear"), like Cmd+K / the `clear`
/// command: purge the scrollback history *and* wipe the visible screen.
/// We drop the history directly, then send Ctrl+L so the shell/TUI repaints
/// its prompt at the top with the cursor in sync (no desync from poking the
/// grid behind the program's back).
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
self.terminal.term.lock().grid_mut().clear_history();
self.scroll_frac = 0.;
// Every mark's row indexed into the history that just went away, so the
// Outline's positions are now meaningless. Drop them rather than leave
// rows that scroll somewhere arbitrary.
self.terminal.marks().clear();
self.terminal.write(vec![0x0c_u8]); // Ctrl+L
cx.notify();
}
/// Swap the primary font face (keeping the configured fallbacks). Lets the
/// settings panel change the font family live; the element re-measures cell
/// geometry on the next prepaint, so the grid reflows automatically.
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();
}
/// Swap the bold face (`None` = synthesize bold from the primary face). The
/// alternate carries the primary's fallback chain so glyph coverage matches.
pub fn set_font_family_bold(&mut self, family: Option<String>, cx: &mut Context<Self>) {
self.font_bold = self.alt_font(family);
cx.notify();
}
/// Swap the italic face (`None` = synthesize italic from the primary face).
pub fn set_font_family_italic(&mut self, family: Option<String>, cx: &mut Context<Self>) {
self.font_italic = self.alt_font(family);
cx.notify();
}
/// Apply OpenType features to the live terminal fonts. `None` restores the
/// terminal-safe default path, where the renderer disables contextual
/// ligatures while building paint faces.
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();
}
/// Build an alternate face from a family name, reusing the primary's
/// fallbacks. `None` → `None` (fall back to synthesizing from `self.font`).
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
})
}
/// Detect command start/finish by watching the PTY's foreground process
/// group, and post a desktop notification when a long-running command
/// finishes while the window is in the background. Called ~1×/second.
fn poll_foreground(&mut self, window: &Window, cx: &mut Context<Self>) {
if self.terminal.exited {
return;
}
let at_prompt = self.terminal.at_prompt();
// A deferred history record is finalized once the shell has reported
// back at its prompt: the daemon's `last_exit` is now this command's.
// Sequence-based, so a fast command whose not-at-prompt window fell
// between polls still gets its exit code.
if self
.pending_history
.as_ref()
.is_some_and(|p| at_prompt && self.terminal.prompt_seq() > p.seq)
{
self.flush_pending_history();
cx.notify();
}
// Re-rank history when the working directory changes (a `cd`), so ghost text
// and completion start favouring commands run in the new directory. Only on
// a real, known change — an unknown cwd keeps the previous ranking.
if let Some(cwd) = self.cwd()
&& self.ranked_cwd.as_ref() != Some(&cwd)
{
self.rerank_history(Some(&cwd));
}
// Shell integration engaging late (a slow rc file finally reported)
// makes a visible integration notice wrong — retract it. The
// once-per-pane latch stays set: the overlay works now, there is
// nothing left to explain.
if self.integration_notice.is_some() && self.terminal.shell_active() {
self.integration_notice = None;
cx.notify();
}
// Redraw when the prompt/running state flips, so the line editor shows or
// hides promptly even when the shell produced no output to trigger a
// repaint (e.g. a command that prints nothing). Without this the editor's
// visibility — computed in `render` — could lag until the next redraw.
if at_prompt != self.last_at_prompt {
self.last_at_prompt = at_prompt;
cx.notify();
}
// Whether the configured notification policy allows a post right now:
// never / only-when-unfocused / always. Shared by the command-finished,
// agent-finished, and agent-waiting notifications.
let notify_allowed = match cx.global::<Config>().notify_on_command_finish {
NotifyMode::Never => false,
NotifyMode::Unfocused => !window.is_window_active(),
NotifyMode::Always => true,
};
// "Command finished" notification: a foreground command (not at prompt)
// that ran long and finished while the window was in the background. When
// the command was a recognized coding agent, brand the notification with
// the agent instead of the generic "command finished" copy.
let running = !at_prompt;
// While a command runs, latch the agent the daemon reports for it — the
// detection poll can land a beat after the command starts, so capture it
// whenever it appears rather than only at the start edge.
if running && self.running_agent.is_none() {
self.running_agent = self.terminal.foreground_agent();
}
// A command finishing (back-to-prompt edge) may have edited files or
// switched branch, so reprobe git after it — captured before the match
// below clears `running_since`.
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 {
// A rich-channel agent already announced each turn's
// end (`stop` events below); a second "finished" on
// process exit would be noise.
Some(_) if self.agent_was_rich => {}
// An agent session ends the moment it finishes — no
// duration floor: "Claude Code finished" is worth saying
// even for a quick turn you stepped away from.
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);
// Refresh the sidebar's git branch/diff line when the working directory
// changed (a `cd`), a command just finished, or an agent turn ended —
// an agent's session is one long foreground command, so its edits would
// otherwise stay invisible until it exits. All rare edges, so the
// off-thread `git` shell-out runs seldom, not every 300ms tick.
//
// Those edges alone left the counts badly stale during the case they
// matter most: a long agent turn writes file after file for minutes
// with nothing to show for it. A tool completion is the one signal that
// the tree may have just moved mid-turn, so it refreshes too — through
// the throttled path, since a busy agent emits them several a second
// and each one would otherwise cost a `git diff` across the repo.
//
// An agent that reports its own cwd through the hook channel wins over
// the proc probe: it tracks internal chdirs the PTY can't observe
// (Claude Code's EnterWorktree) and works where the proc fallback
// doesn't (Windows). The claim dies with the session (`session-end`
// clears it, and the agent leaving the foreground drops the whole
// state), so an exited agent falls back to the pane's real directory.
// The agent's report goes through the same remote gate as the pane's own
// cwd. A native-SSH pane keeps sentinel-sourced agent state on purpose
// (`spawn_native_ssh`), so an agent running *on the remote host* reports
// a remote path — and being first in the chain it would win over
// `local_cwd` unconditionally and hand that path straight to the local
// `git`, which is the collision `local_cwd` exists to prevent.
let session = self.terminal.agent_session();
// A count that moved means at least one tool finished since the last
// tick. With no session the counter resets, so a fresh agent's very
// first tool call still reads as activity.
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
.remote_context()
.is_none()
.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);
}
}
/// Kick off an off-thread git probe for `cwd` and fold the result into the
/// shared per-repo [`GitStatusCache`] on the main thread. The cache
/// brackets the flight (`begin_probe`/`finish_probe`): a probe already in
/// flight for the same cwd absorbs this trigger instead of spawning a
/// duplicate `git` shell-out, and reruns once when it lands. With no cwd
/// (e.g. a remote pane, where a local `git` would be meaningless) the pane
/// simply stops reading a status. Callers must source the cwd from
/// [`local_cwd`](Self::local_cwd): a remote pane *does* get a cwd once its
/// OSC 7 lands, so "remote panes have no cwd" holds only before that and
/// cannot be what keeps the local probe away from a remote path.
///
/// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache
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;
};
cx.default_global::<GitStatusCache>(); // first probe of the process creates it
let claimed = cx.update_global::<GitStatusCache, _>(|cache, _| match trigger {
GitRefresh::Edge => cache.begin_probe(&cwd),
GitRefresh::Opportunistic => cache.begin_probe_throttled(&cwd, OPPORTUNISTIC_GIT_GAP),
});
if !claimed {
return;
}
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn({
let cwd = cwd.clone();
async move { crate::terminal::git_status::probe(&cwd) }
})
.await;
// Land the result in the shared cache before touching the pane,
// and whether or not the pane still exists: the in-flight claim is
// keyed by *cwd*, so a pane closed mid-probe that never released
// its claim would wedge the git line of every other pane in that
// directory — permanently, since nothing else ever clears it.
//
// Landing through `update_global` wakes the sidebar's
// `observe_global`, so every pane in the repo repaints — not just
// this one.
let rerun =
cx.update_global::<GitStatusCache, _>(|cache, _| cache.finish_probe(&cwd, result));
// A trigger arrived while we flew; go once more so its state is
// observed — unless this pane has since left that cwd. Only edge
// triggers set that flag, so the rerun is an edge too.
if rerun {
let _ = this.update(cx, |view, cx| {
if view.git_status_cwd.as_deref() == Some(&cwd) {
view.refresh_git_status(Some(cwd), GitRefresh::Edge, cx);
}
});
}
})
.detach();
}
/// Fold the pane's rich agent status into turn-level notifications and the
/// status dot. Runs on the same cadence as the notification poll above.
///
/// Only *transitions* act: entering `Waiting` says the agent needs you
/// (the reason attached), and a `Working → Done` edge says the turn
/// finished — with its duration when we saw it start. Non-rich (fallback)
/// state paints the dot but stays silent: the agent's own OSC notification
/// was already toasted by the reader thread, and echoing it would double
/// up. Attach replays land as a bare status with no observed transition
/// history, so a restored `Done` never re-notifies.
///
/// Returns whether a turn just ended (a transition *into* `Done`) — the
/// caller uses it to reprobe git: an agent's whole session is one long
/// foreground command, so the back-to-prompt edge that normally refreshes
/// the branch/diff line never fires while it works.
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 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);
// Read/unread for the green Done dot: a turn just finished is "unread"
// only if you weren't looking (focused pane = you watched it finish, so
// it's already read). Any non-Done status has no result to be unread.
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);
}
// Done only counts off an *observed* turn (working/waiting seen
// live), so an attach replay of old state stays quiet.
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);
}
_ => {}
}
// Status changed: repaint so the avatar dot / sidebar line track it.
cx.notify();
turn_finished
}
/// True when the shell sits idle at its prompt: the PTY's foreground process
/// group is the shell's own (established as the first group we observe), as
/// opposed to a foreground command having taken over the terminal. `false`
/// while a command runs or before the group can be read. Reuses the same
/// `prompt_pgid` baseline that `poll_foreground` learns.
fn at_shell_prompt(&self) -> bool {
self.terminal.at_prompt()
}
/// The shell cursor's current viewport cell `(row, col)`, accounting for
/// scrollback offset — the same mapping `element::build_grid` uses to place
/// the block cursor. `None` only when the cursor is scrolled off the top of
/// the viewport. Used to anchor the inline line editor right where the shell
/// prompt ends.
///
/// The cursor's `Hidden` *shape* is deliberately ignored. A full-screen TUI
/// (e.g. Claude Code) hides the cursor with DECTCEM (`\e[?25l`) and can hand
/// back to the shell prompt — or exit — before a matching `\e[?25h` reaches
/// our local grid, leaving the shape stale-`Hidden` while the shell is
/// already idle at its prompt. These callers only run while `input_active()`
/// (at the prompt, off the alt screen), where the cursor *position* is valid
/// even if the shape is momentarily hidden. Treating hidden as `None` here
/// made `render_input_bar` fall back to `(0, 0)` and paint the caret in the
/// top-left corner; `element::build_grid` already ignores the shape the same
/// way when anchoring the IME window.
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))
}
/// How many rows the whole surface — grid and input overlay together —
/// shifts up so a wrapped command at a bottom-of-screen prompt stays
/// visible, emulating the scroll the shell itself would perform if the
/// input were echoed. `element::paint` raises the grid origin by this many
/// lines (clipping the top rows) and `render_input_bar` anchors the same
/// rows higher, so the wrapped tail lands in the vacated strip. Zero
/// whenever nothing overflows, while scrolled into history (the overlay is
/// off-screen anyway and the view shouldn't fight the user's scroll), or
/// in reverse-search mode (a single fixed row).
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)
}
/// Handle a left click while the command editor is live: if it lands on the
/// input line, move the caret to the clicked position and report `true` (so
/// the caller skips starting a terminal text-selection). The line is rendered
/// starting at the shell's cursor cell, so the clicked char index is the
/// column offset from there. (Approximate for wide CJK glyphs, which span two
/// cells — fine for typical ASCII command lines.)
/// Map a click cell `(col, row)` to a char index in the edited line, accounting
/// for wrapping: the input occupies `prompt_cols + len` cells laid out grid-row
/// by grid-row from the prompt cell. With `clamp`, positions before/after the
/// input snap to `0`/`len` (for drags); without it, they return `None` (so a
/// click outside the input isn't treated as an editor click).
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 {
// Shift+click extends the selection from the current caret to the
// click (anchoring one at the old caret if none is active), matching
// shift-arrow selection; a plain click collapses to the caret.
1 if shift => {
self.cmd.extend_to(idx);
self.editor_selecting = true; // a drag from here keeps extending
self.editor_drag_word = None;
}
1 => {
self.cmd.set_cursor(idx);
self.cmd.clear_selection();
self.editor_selecting = true; // a drag from here extends selection
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);
// Drag now grows the selection by whole words around this one.
self.editor_selecting = true;
self.editor_drag_word = self.cmd.selection();
}
_ => {
self.cmd.select_all();
// The whole line is selected; a drag has nothing left to extend.
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
}
/// Extend the editor selection during a left-drag that began on the input.
/// Returns whether it handled the drag (so the terminal selection is skipped).
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;
};
// A drag begun on a double-click extends by whole words; otherwise by char.
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
}
/// Whether the local line editor should be live and focused: idle at a shell
/// prompt, not on the alternate screen, no search bar open, process alive.
/// Everywhere else this is `false`, so the raw terminal keeps the keyboard and
/// behaves exactly as without the editor.
pub fn input_active(&self) -> bool {
// Suppress our command editor only while the search field actually holds
// keyboard focus (it claims Tab / ↑ / ↓ / typing). If search is open but
// blurred — e.g. the user clicked back into the terminal — the editor must
// resume, otherwise keys fall through to the raw PTY path and can't be edited.
if self.terminal.exited || self.search_focused {
return false;
}
if self.on_alt_screen() {
return false;
}
if self.shell_vi_prompt() {
return false;
}
// A Tab handoff gave this prompt's line to the shell; until a command
// runs and a fresh prompt cycle starts, the shell's editor owns it,
// and re-engaging ours would fork the two line buffers.
if self.editor_handoff == Some(self.terminal.prompt_cycle()) {
return false;
}
self.at_shell_prompt()
}
fn shell_vi_prompt(&self) -> bool {
self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen()
}
/// True while a Tab handoff has given the current prompt's line to the
/// shell (see [`Self::handoff_tab_to_shell`]) and the shell is still in
/// that prompt cycle. Over once a command runs and the next prompt
/// arrives (a false→true `at_prompt` edge bumps the cycle).
fn handoff_active(&self) -> bool {
self.editor_handoff == Some(self.terminal.prompt_cycle())
&& self.terminal.at_prompt()
&& !self.on_alt_screen()
}
/// True while the shell's own line editor owns the prompt line — a
/// vi-mode prompt, or one whose line a Tab handoff shipped over. Raw
/// input then goes to the PTY with no hold and no typeahead record:
/// those bytes land on zle's line and are the shell's to keep, so a
/// deferred `^U` wipe would erase text the user can see.
fn shell_owns_prompt(&self) -> bool {
self.shell_vi_prompt() || self.handoff_active()
}
/// True while the emulator is on the alternate screen — a full-screen TUI
/// owns the pane, so raw input belongs to that program, not the shell's
/// next command line.
fn on_alt_screen(&self) -> bool {
self.terminal
.term
.lock()
.mode()
.contains(TermMode::ALT_SCREEN)
}
/// Handoff once zle is reading at the new prompt: wipe the type-ahead it
/// just consumed and adopt it into the editor (see the `typeahead` module
/// docs for the full failure mode). The `^U` (kill-whole-line — same
/// binding in zsh emacs/vi-insert, bash and fish) is written *after*
/// every stray byte, and the TTY queue is FIFO, so zle always reads the
/// strays first and then the wipe — correct with no timing assumptions.
/// The seed is *prepended*: the editor engages at `133;D` but this flush
/// waits for `133;B` (`zle_reading` — a ^U written while precmd hooks
/// still run in canonical mode is kernel-echoed as literal `^U` junk),
/// and anything typed in between already sits in the editor,
/// chronologically *after* the strays. Runs every render with the editor
/// live; an untouched record drains to `None` and sends nothing.
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);
}
}
/// The editor is about to write bytes the shell will act on (a submitted
/// line, ^D EOF) while gap typeahead may still sit unadopted on zle's
/// line (its wipe waits for `zle_reading`). Wipe first — FIFO puts the
/// ^U ahead of the caller's bytes — and drop the seed: grafting it into
/// an action the user just chose would run something they never saw.
fn wipe_pending_typeahead(&mut self) {
if self.typeahead.drain().is_some() {
self.terminal.write(vec![0x15]);
}
}
/// True when gap input may be held for the editor: shell integration is
/// live (a prompt will come and adopt it) and no full-screen TUI owns the
/// pane. Only consulted on the raw path, so "the editor is disengaged" is
/// already implied.
fn gap_holdable(&self) -> bool {
self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt()
}
/// Write printable gap text (IME commit, paste) toward the shell: offered
/// to the hold when reconstructable (see `hold`), otherwise released +
/// written raw and recorded for the deferred wipe. `bytes` is the exact
/// PTY encoding (paste may be bracketed-wrapped).
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 {
// Unreconstructable (control chars / TUI input): anything held
// must precede these bytes on the wire.
self.release_hold();
}
self.terminal.write(bytes);
let alt = self.on_alt_screen();
self.typeahead.observe(RawInput::Text(text), alt);
}
/// Release any held gap input to the PTY (order-preserving) and record it
/// for the deferred wipe; the rest of this gap is raw passthrough.
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);
}
}
/// Start the one-shot dump timer for a freshly opened hold window.
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();
}
/// The hold window lapsed with the editor still disengaged: the command
/// is long-running (or reading stdin) — release the bytes to the PTY and
/// record them for the deferred wipe.
fn dump_hold(&mut self, epoch: u64, cx: &mut Context<Self>) {
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();
}
}
/// readline's accept-line, as the editor means it: with a completion
/// candidate highlighted, take the candidate (a second stroke then runs the
/// line); otherwise close any menu and submit. Shared by Enter and its
/// control-code aliases ⌃J / ⌃M.
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);
}
/// Ship the edited command line to the PTY — the whole line plus a carriage
/// return — record it in history, then clear the editor for the next command.
fn submit_command(&mut self, cx: &mut Context<Self>) {
if self.terminal.exited {
return;
}
// A sub-frame race can land Enter before the render that adopts held
// gap input: fold it in first so the submitted line is what the user
// actually typed.
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
let line = self.cmd.text();
// Record in history (skip blanks and immediate duplicates for ↑/↓ recall),
// but always tally the run — count, the directory it ran in, and when —
// for ranking and the Ctrl+R menu, then refresh the ranked view for the
// current directory. The file record is deferred until the shell reports
// back at its prompt, so it can carry this run's exit code; a previous
// record still deferred goes out first.
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();
// Any gap typeahead still waiting for its wipe (the ^U is deferred
// until zle reads) would prefix the submitted line on zle's side —
// "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U
// ahead of the line bytes.
self.wipe_pending_typeahead();
// One paste + one CR when the shell takes bracketed paste, so a
// multi-line command costs one prompt cycle instead of one per line
// (see `submit_bytes`); per-line CRs otherwise.
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;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
cx.notify();
}
/// Recall the previous (older) history entry into the editor (↑). On the first
/// step it stashes the in-progress line so ↓ can restore it.
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, // already at the oldest
Some(i) => i - 1,
};
self.history_nav = Some(next);
self.cmd.set(&self.history[next]);
cx.notify();
}
/// Move to the next (newer) history entry (↓); stepping past the newest
/// restores the stashed in-progress line.
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 {
// Past the newest entry: back to the line the user was typing.
self.history_nav = None;
let stash = std::mem::take(&mut self.history_stash);
self.cmd.set(&stash);
}
cx.notify();
}
/// Re-rank `history_ranked` by frecency for `cwd`, so commands previously run
/// in that directory float to the top of ghost text and completion. Records the
/// directory used, so `poll_foreground` can skip re-ranking until it changes.
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);
}
/// Write the deferred history record (see [`PendingHistory`]), if any. The
/// exit code is attached only when the shell has reported back *and* sits
/// at its prompt again — then `last_exit_code()` is this command's;
/// otherwise (pane going away mid-command, a new submit racing in) the
/// record goes out without one, like a plain shell history line.
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(&p.line, p.cwd.as_deref(), p.ts, exit);
}
/// The autosuggestion (ghost text): the most *frecent* history entry that
/// starts with the current line, when the caret is at the end. Returns the
/// *full* suggested line; the renderer shows the remainder in muted text and
/// Right / Ctrl+F accepts it. `None` when the line is empty, the caret isn't at
/// the end, or nothing matches. Ranking by frecency (not raw recency) means the
/// command you actually run a lot wins over the last thing you happened to type.
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()
}
/// Raise the one-shot integration notice if this Ctrl+R fell through to the
/// raw PTY path because shell integration never engaged (#46). Silent when
/// the raw path is expected instead: integration did engage and a foreground
/// command merely owns the PTY, a full-screen TUI owns the pane, or the
/// shell is still inside its startup grace window (slow rc files haven't
/// reached the first prompt report yet).
///
/// Shows a generic message immediately, then refines it off-thread: the
/// daemon's foreground query sees the process actually holding the PTY, and
/// when that is a known shim (it exec'd over the shell we spawned), naming
/// it turns "the feature looks broken" into "here is the culprit".
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;
cx.spawn(async move |this, cx| {
// Best-effort: no daemon / unknown pane / unreadable process just
// leaves the generic message standing.
let fg = cx
.background_executor()
.spawn(async move {
RemoteTerminal::list_panes()
.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();
}
});
}
// Reading time is over either way; a keystroke usually beat us here.
cx.background_executor()
.timer(INTEGRATION_NOTICE_TIMEOUT)
.await;
let _ = this.update(cx, |view, cx| {
if view.integration_notice.take().is_some() {
cx.notify();
}
});
})
.detach();
}
/// Begin a Ctrl+R history search (no-op if one is already active). Opens
/// with the empty query's frecency listing, so the menu is browsable
/// before a single key is typed.
fn start_reverse_search(&mut self) {
if self.reverse_search.is_none() {
self.reverse_search = Some(ReverseSearch::new(&self.history, &self.history_frecency));
}
}
/// Handle a key while a reverse search is active. The search itself owns the
/// query/match logic (`reverse_search` module); the view just applies the
/// resulting [`reverse_search::Action`] and repaints.
fn handle_reverse_search_key(&mut self, ks: &gpui::Keystroke, cx: &mut Context<Self>) {
// Printable text typed into the query. A CJK input source routes it through
// the IME (`input_text` → `push_query`), but a plain ASCII input source —
// and Linux, where `prefers_ime_for_printable_keys` is false — delivers it
// here as an ordinary key event carrying `key_char`. Without this the search
// field can only be typed into via an IME: Ctrl+R opens, but ASCII
// keystrokes vanish. Mirror the editor's `key_char` path (`handle_editor_key`);
// control / Cmd / Alt chords and non-printable keys (Enter/Backspace/Esc have
// no printable `key_char`) fall through to the control-key handling below.
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) => {
// Cmd+Enter: accept the selection and run it in one stroke.
self.reverse_search = None;
self.cmd.set(&line);
self.submit_command(cx);
}
}
cx.notify();
}
/// Hand the prompt line over to the shell so *its* keymap answers a chord
/// tty7 declines: ship the locally edited text to the PTY (no newline),
/// clear the editor, send `chord`, and suspend the local editor until the
/// shell's next report. From here the shell's own editor holds the text —
/// re-engaging ours mid-line would fork the two buffers (its Enter would
/// submit an empty local line on top of zle's populated one).
///
/// A multi-line draft can't make the trip (see below): the chord is
/// swallowed and the line stays local.
fn handoff_line_to_shell(&mut self, chord: &[u8], cx: &mut Context<Self>) {
// Fold in any gap input still held, so the shipped line is what the
// user actually typed.
if let Some(net) = self.hold.engage() {
self.cmd.prepend_str(&net);
}
let line = self.cmd.text();
// An embedded newline would submit on the shell side (zle runs the
// line on `\r`), so a multi-line draft can't be handed over losslessly
// — keep it local and swallow the chord as before.
if line.contains('\n') {
cx.notify();
return;
}
self.close_completion();
// A pending typeahead wipe's deferred `^U` would erase the very text
// we're about to ship; flush it first (FIFO keeps it ahead).
self.wipe_pending_typeahead();
// Chars right of the caret: after the shipped text lands, walk zle's
// cursor back over them so the shell acts on the word the caret was
// on, not the line's tail.
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);
}
/// Hand the line over and let the shell have the Tab, so its native
/// completion (compsys, fzf-tab, …) answers what tty7 has nothing for.
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);
}
/// Tab completion over our own engine (command names in command
/// position, filesystem paths elsewhere — history is deliberately absent:
/// whole-line recall is ghost text's and Ctrl+R's job). A fresh Tab applies a
/// unique match immediately; multiple matches fill the candidates' longest
/// common prefix and open the menu as a *picker* with the first row
/// highlighted — the line isn't touched again until a candidate is accepted.
/// With the menu open, Tab fills any further common prefix, else moves the
/// highlight (`forward` reverses for Shift-Tab).
fn complete_tab(&mut self, forward: bool, cx: &mut Context<Self>) {
// Ctrl+R search owns the keyboard: `self.cmd` still holds the stale
// pre-search line, so neither completing it nor shipping it to the
// shell makes sense here.
if self.reverse_search.is_some() {
return;
}
// tty7 completion switched off: every Tab goes to the shell.
if !cx.global::<Config>().tab_completion {
self.handoff_tab_to_shell(!forward, cx);
return;
}
if self.completion.is_some() {
self.completion_tab_step(forward, cx);
return;
}
// Fresh completion. Path candidates come off the local filesystem, so
// they need a local cwd — a remote pane passes `None` and gets command
// completion only. Falling back to tty7's own directory there would
// offer *this* machine's filenames for insertion into a remote command
// line, where they don't exist.
let cwd = match self.remote_context() {
Some(_) => None,
None => self.local_cwd().or_else(|| std::env::current_dir().ok()),
};
let line = self.cmd.text();
let cursor = self.cmd.cursor();
let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else {
// Nothing to offer. Don't swallow the keypress (#136) — hand the
// line to the shell and let its completion have the Tab.
self.handoff_tab_to_shell(!forward, cx);
return;
};
// With generators inbound the candidate set is still growing, so the
// usual "unique sync match → accept" and "fill the common prefix"
// shortcuts are unsafe: a result landing a moment later could add or
// change the pick. Only the fully-static case (no pending) keeps the
// classic behavior byte-for-byte.
let has_pending = !comp.pending.is_empty();
if !has_pending && comp.candidates.len() == 1 {
// Unique match: accept it outright.
let c = comp.candidates[0].clone();
self.completion_insert(&c, c.start);
self.cursor_visible = true;
cx.notify();
return;
}
// The word range is carried by any candidate; with none (pure-generator
// slot) derive it from the caret so the session still knows what it
// replaces.
let (word_start, word_end) = match comp.candidates.first() {
Some(c) => (c.start, c.end),
None => (word_start_of(&line, cursor), cursor),
};
let word: String = line
.chars()
.skip(word_start)
.take(word_end - word_start)
.collect();
let s = CompletionSession::new(word_start, word.clone(), comp.candidates);
if !has_pending
&& let Some(lcp) = s.common_prefix()
&& lcp.chars().count() > word.chars().count()
{
// Static-only: fill the longest common prefix when it extends the
// typed word. All candidates share it, so the fill never invalidates
// the set. With generators pending we skip this — the eventual set
// may share a shorter prefix, and mutating the line before results
// arrive would be jarring.
self.apply_candidate(&line, word_start, word_end, &lcp);
}
let generation = self.open_completion(s);
self.cursor_visible = true;
cx.notify();
// Kick off each generator on the background executor and merge results
// back on the main thread, tagged with this session's generation.
// Generators are local shell-outs and only ever come from `complete`'s
// `Some(cwd)` branch, so a remote pane has none to run.
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();
}
}
/// Open a completion menu and bump the generation tag, returning it so a
/// caller spawning generators can stamp their in-flight results. Every open
/// gets a fresh generation, so a slow generator from a prior session can't be
/// mistaken for one belonging to this menu.
fn open_completion(&mut self, session: CompletionSession) -> u64 {
self.completion = Some(session);
self.completion_generation = self.completion_generation.wrapping_add(1);
self.completion_generation
}
/// Close the menu, bumping the generation so any generator still running for
/// it is orphaned — its result will be dropped on arrival. No-op when nothing
/// is open.
fn close_completion(&mut self) {
let _ = self.take_completion();
}
/// Take the open session (for accept), bumping the generation like
/// [`Self::close_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
}
/// Merge a finished generator's candidates into the open menu. Dropped unless
/// the session that spawned it is still the current one (`generation` match) —
/// the guard against a result outliving its menu. Rebuilds the candidate set
/// against the *live* word (the caret may have moved on while the generator
/// ran) and repaints.
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();
}
}
/// Tab / Shift-Tab with the menu open: first try extending the line to the
/// filtered candidates' common prefix (bash-style fill); when that makes no
/// progress, move the highlight instead. A fill that pins down a single
/// candidate accepts it outright.
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);
}
/// Move the completion highlight (Tab cycling and ↑/↓). Visual only — the
/// editor line changes on accept, not while browsing.
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();
}
}
/// Accept the highlighted candidate: write it into the line and close the
/// menu. The command does not run — a second Enter (or Cmd+Enter in one
/// stroke) submits.
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();
}
/// Write `cand` into the editor over chars `[start, caret)` — the accept
/// action. Directories keep a trailing `/` so a further Tab descends; other
/// candidates get a trailing space only when the caret is at the end of the
/// line (mid-line, the existing tail already separates the word).
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);
}
/// Re-filter the open menu after an edit at the caret: the live word must
/// still extend the word the menu opened on and keep at least one candidate,
/// else the menu closes. Whitespace in the word (a new argument) closes it
/// too. No-op when no menu is open.
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();
}
}
/// Splice `text` into `orig` over the char range `[start, end)` and put the
/// result into the editor. Delegates to `completion::Replacement` so the
/// edit is unit-tested there.
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);
}
/// Commit text from the terminal's IME handler. While idle at the prompt this
/// inserts into our local command editor; while a command runs it writes
/// straight to the PTY (bare-terminal behavior). Covers both plain typed text
/// (routed through the IME) and committed CJK characters.
pub fn input_text(&mut self, text: &str, cx: &mut Context<Self>) {
self.commit_text(text, cx);
}
/// See `input_text`. The single text-commit path, split by whether the editor
/// is live at the prompt.
pub fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
if self.terminal.exited || text.is_empty() {
return;
}
// While reverse-searching, typed text edits the query, not the line.
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() {
// Editing the command line locally — insert at the caret. Typing
// breaks out of history navigation; an open completion menu
// re-filters to the extended word (and closes once nothing matches).
self.cmd.insert_str(text);
self.history_nav = None;
self.editor_goal_col = None;
self.completion_refilter();
self.cursor_visible = true;
cx.notify();
return;
}
// Gap typing: offered to the hold first (a fast command's typeahead
// then lands in the editor without ever echoing), else written raw
// and kept in step with the typeahead record (see `hold`/`typeahead`).
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
// Keep the cursor solid while committing input (resets the blink phase).
self.cursor_visible = true;
let mut term = self.terminal.term.lock();
term.selection = None;
term.scroll_display(Scroll::Bottom);
self.scroll_frac = 0.;
drop(term);
cx.notify();
}
/// Set the IME pre-edit (composing) text to display at the cursor.
pub fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
self.marked_text = text;
cx.notify();
}
/// Clear the IME pre-edit state.
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 };
// Shift+click extends the existing selection to the click instead of
// starting over (à la iTerm2). A plain click always leaves a
// collapsed Simple selection behind, so the anchor is wherever the
// last gesture ended.
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, // word
n if n >= 3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
let mut selection = Selection::new(ty, point, side);
// Double-click smart selection: a URL / path / email / bracket pair /
// CJK word containing the clicked word replaces the plain word span.
// Boundary-flanked candidates anchor a Semantic selection (keeping
// the drag gesture word-wise); exact ones use Simple so alacritty
// can't re-expand the endpoints past the smart boundary.
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();
}
/// Drive selection auto-scroll from a drag's vertical overshoot past the
/// pane bounds, in lines (0 while the pointer is inside, positive above
/// the top edge). Called on every left-drag move: entering the edge zone
/// arms a repeating task that scrolls the scrollback and keeps extending
/// the selection at the edge row; later moves just retune its speed and
/// column, and moving back inside (or releasing) stops it.
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 {
// The running task reads the fresh state on its next tick.
return;
}
// First step immediately so a quick flick past the edge still moves,
// then keep stepping on a timer. The task stops itself once the state
// clears (pointer back inside, drag ended), a newer task supersedes
// it (epoch mismatch), or the view is dropped.
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();
}
/// One auto-scroll step: scroll by an amount that grows with the
/// overshoot, then re-anchor the selection's moving end to the edge row
/// it is pushing past (top row when scrolling up, bottom when down).
/// Returns whether the task should keep ticking. Scrolling clamps at the
/// history limits, so pinning the pointer past the edge at the top of
/// scrollback just idles until it moves.
fn drag_scroll_tick(&mut self, epoch: u64, cx: &mut Context<Self>) -> bool {
if epoch != self.drag_scroll_epoch {
// Superseded by a newer task: the state now belongs to it, so just
// bow out without clearing anything.
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
}
/// Mouse-up: the selection gesture (if any) is over. With copy-on-select
/// enabled, the selection the gesture drove goes straight to the clipboard
/// — [`select_end_copy`] picks the buffer, and empty selections (a plain
/// click repositioning the caret / collapsing the old selection) write
/// nothing because both copy paths drop empty text.
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;
// Mouse-tracking reports and alternate-scroll arrow keys consume whole
// lines, so those paths accumulate fractional deltas and spend only the
// whole part: rounding each trackpad event separately either discards
// them all (slow scrolls stall) or over-counts them (each tiny nudge
// becomes a full line). Shift forces local scrollback, matching
// `scroll`'s own routing.
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;
}
// Local scrollback keeps the fraction instead: the view position is
// continuous and every wheel event moves pixels, not lines.
self.smooth_scroll(delta, cx);
}
/// The pane's OSC 133 command marks, newest last — the Outline's rows.
pub fn command_marks(&self) -> Vec<crate::terminal::marks::CommandMark> {
self.terminal.marks().list()
}
/// Scroll so the command recorded at `row` sits near the top of the viewport.
/// Returns `false` when the mark has aged out of the scrollback, so the
/// caller can say so rather than leaving the user staring at an unchanged
/// screen wondering whether the click registered.
///
/// `row` is an index from the top of history, which drifts once the
/// scrollback saturates (see the `terminal::marks` docs). A drifted mark
/// still scrolls *somewhere* — it just may not be the exact prompt — so the
/// only failure reported here is a row that has fallen off entirely.
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;
}
// `display_offset` counts *up* from the bottom of history, so the offset
// that puts `row` at the viewport's top line is its distance from there.
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);
// A jump lands wherever it lands; the fractional offset is a smooth-scroll
// artifact and would otherwise shift the paint off the line boundary.
self.scroll_frac = 0.;
cx.notify();
true
}
/// Scroll the local scrollback by a possibly-fractional number of lines,
/// pixel-smooth: whole lines go to the emulator's `display_offset`, the
/// remainder stays in `scroll_frac` and shifts the paint. The position may
/// come to rest between line boundaries, like a native scroll view.
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();
}
}
/// The grid line a screen `row` currently maps to, or `None` when that row
/// is outside the grid.
///
/// Mandatory before indexing: `Grid`'s `Index<Line>` only `debug_assert`s
/// the bound, so a release build walks off the storage and panics on the
/// slice check instead. A remembered cell goes stale whenever the grid
/// shrinks under it — split a pane, drag the window smaller — and the
/// callers here run inside gpui's `extern "C"` input callbacks, where that
/// panic can't unwind and aborts the process.
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)
}
/// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or
/// existing file or directory path detected in the row text). Returns true if one opened.
pub fn open_link_at(&self, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
if !cx.global::<Config>().link_url {
return false;
}
let term = self.terminal.term.lock();
let Some(line) = Self::grid_line(&term, row) else {
return false;
};
let cols = term.columns();
if col >= cols {
return false;
}
// 1) Explicit OSC 8 hyperlink carried on the cell.
let cell = &term.grid()[line][Column(col)];
if let Some(hl) = cell.hyperlink() {
let uri = hl.uri().to_string();
drop(term);
self.open_url(&uri, cx);
return true;
}
// 2) Fall back to detecting a bare URL or file path in the row's text.
let mut text = String::with_capacity(cols);
for c in 0..cols {
text.push(term.grid()[line][Column(c)].c);
}
drop(term);
// A relative path in the output is resolved against the cwd and
// stat-checked, then handed to the local file opener — so a remote
// pane's cwd must not be used. There, only absolute-looking local hits
// and URLs remain clickable.
let cwd = self.local_cwd();
if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) {
match link.target {
LinkTarget::Url(url) => self.open_url(&url, cx),
LinkTarget::File { path, line, column } => {
// A configured template (e.g. opening the file in an editor)
// takes precedence; otherwise fall back to the OS opener.
match cx.global::<Config>().link_file_command.as_deref() {
Some(template) => run_file_command(template, &path, line, column),
None => open_file_path(&path),
}
}
}
true
} else if self.can_forward_loopback(cx)
&& let Some((_, _, url)) = super::loopback::loopback_url_span_at(&text, col)
{
self.open_url(&url, cx);
true
} else {
false
}
}
fn open_url(&self, url: &str, 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 => {}
}
}
fn forwarded_loopback_url(&self, url: &str, cx: &mut Context<Self>) -> LoopbackOpen {
if !self.can_forward_loopback(cx) {
return LoopbackOpen::NotLoopback;
}
let Some(loopback) = super::loopback::parse_loopback_url(url) else {
return LoopbackOpen::NotLoopback;
};
match RemoteTerminal::ensure_loopback_forward(
self.pane_id,
loopback.forward_host(),
loopback.port,
) {
Ok(forward) => LoopbackOpen::Forwarded(loopback.forwarded_url(forward.local_port)),
Err(e) => {
log::warn!("failed to forward loopback URL {url}: {e}");
LoopbackOpen::ForwardFailed
}
}
}
fn can_forward_loopback(&self, cx: &mut Context<Self>) -> bool {
// A loopback one-click forward runs over the pane's native russh connection
// (FR-F4, direct-tcpip). Only native-SSH panes have one.
cx.global::<Config>().ssh_loopback_forward
&& self
.terminal
.remote_context()
.is_some_and(|remote| remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh)
}
/// Update the remembered hovered link for the screen cell `(col, row)` and
/// repaint if it changed. Returns whether a link sits under the cursor, so the
/// element can switch to a pointing-hand cursor. Cheap on the common case: any
/// non-URL cell resolves to `None` and bails.
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));
// URL detection off → never underline or switch to the pointing hand,
// and drop any underline a prior hover left behind.
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
}
/// Forget any hovered link (mouse left the grid, or moved onto plain text),
/// repainting to drop the underline.
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();
}
}
/// Resolve the link span at screen cell `(col, row)`: an OSC 8 hyperlink (the
/// contiguous run of cells sharing the same target), a bare URL token, or an
/// existing file or directory path in the row text. Mirrors [`open_link_at`](Self::open_link_at)'s
/// detection so the underline covers exactly what a Cmd+click would open.
fn link_span_at(
&self,
col: usize,
row: usize,
include_files: bool,
include_loopback: bool,
) -> Option<HoveredLink> {
let term = self.terminal.term.lock();
let line = Self::grid_line(&term, row)?;
let cols = term.columns();
if col >= cols {
return None;
}
// 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run carrying
// the same URI, which may be wider than the visible link text.
if let Some(hl) = term.grid()[line][Column(col)].hyperlink() {
let uri = hl.uri().to_string();
let same = |c: usize| {
term.grid()[line][Column(c)]
.hyperlink()
.is_some_and(|h| h.uri() == uri)
};
let mut start = col;
while start > 0 && same(start - 1) {
start -= 1;
}
let mut end = col;
while end + 1 < cols && same(end + 1) {
end += 1;
}
return Some(HoveredLink {
line: line.0,
start,
end,
});
}
// 2) Bare URL or file path detected in the row's text.
let mut text = String::with_capacity(cols);
for c in 0..cols {
text.push(term.grid()[line][Column(c)].c);
}
drop(term);
// Same gate as the click path above — hover must not underline a link
// the click cannot open.
let cwd = self.local_cwd();
let link =
super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| {
include_loopback.then(|| {
super::loopback::loopback_url_span_at(&text, col).map(|(start, end, url)| {
super::search::LinkMatch {
start,
end,
target: LinkTarget::Url(url),
}
})
})?
})?;
Some(HoveredLink {
line: line.0,
start: link.start,
end: link.end,
})
}
/// The inline command line, anchored right where the shell prompt
/// ends (the cursor cell) and shown only while `input_active`. It carries the
/// terminal's own font over a transparent background, with no chrome of its
/// own, so the typed text reads as a natural continuation of the shell prompt
/// rather than a separate widget. The terminal's own block cursor is hidden
/// while the editor is live (see `element::paint`), leaving the field's caret
/// as the single cursor.
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);
// The overlay rides the same upward shift `element::paint` applies to
// the grid when the wrapped input would spill past the bottom, so its
// first line keeps hugging the (shifted) prompt row. May go negative
// when the input is taller than the screen — the parent's
// `overflow_hidden` clips the rows that scroll off the top.
let shift = self.input_scroll_rows();
let cy_top = px(GRID_PAD_Y) + self.line_height * (crow as f32 - shift as f32);
// Reverse-search mode replaces the line with a `(reverse-i-search)` prompt
// showing the query and the selected match; the ranked candidates float
// in their own menu (`render_reverse_search_menu`).
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;
// The theme's dedicated selection color, kept translucent so the colored
// text still reads through it.
let mut sel_bg = theme.selection;
sel_bg.a = 0.55;
let cell_w = self.cell_width;
let lh = self.line_height;
// The blinking bar caret should be as tall as the *text*, not the full
// line box: `lh` is `font_size × line_height_mul` (e.g. 1.35×), so a
// full-height bar visibly pokes above/below the glyphs (which the cells
// centre within `lh`). Size it to roughly the glyph extent and centre it
// in the cell so it hugs the text like a normal editor caret.
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);
// Per-char syntax color, expanded from the highlighter's spans (which tile
// the whole line), so each character cell can be colored independently.
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);
}
}
// Render the input one fixed-width cell per character. This makes the wrap
// deterministic (exactly grid-width cells per row), so a click anywhere —
// including a wrapped continuation line — maps back to a char index (see
// `editor_char_index`). The caret is an absolutely-positioned bar inside a
// cell, so it never perturbs cell widths.
let cursor_on = self.cursor_visible;
// The editor caret honours the configured `cursor_style`, matching the
// grid cursor `paint_cursor` draws while a program runs — otherwise the
// shape setting would appear to do nothing at the (most common) prompt.
// Bar = a thin vertical line; Block = a translucent fill over the cell (so
// the glyph still reads through, like the grid block); Underline = a line
// along the cell's baseline. All are absolutely positioned inside their
// relative parent cell, so `w_full` spans exactly one (wide-aware) cell.
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| {
// Wide (CJK / fullwidth / emoji) glyphs occupy two terminal cells, so
// size the box accordingly — otherwise the glyph is clipped by the
// next cell and the click→char mapping drifts.
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()
};
// A blank cell of the given width and the line height — used for the
// leading prompt spacer and for a selected/caret slot standing in for a
// hard line break.
let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh);
// The buffer's logical lines, each rendered as its own `flex_wrap` row and
// stacked in a column, so an embedded `'\n'` (from a pasted multi-line
// command, or Shift/Opt+Enter) shows as a real line break instead of
// flowing into one wrapped blob. Within a line, soft-wrapping is left to
// `flex_wrap` exactly as before. `lines` grows a fresh row on each `'\n'`.
let mut lines: Vec<Vec<gpui::AnyElement>> = vec![vec![
// Leading spacer the width of the shell prompt: the first line begins
// right after the prompt; continuation lines start at the grid's left
// edge, matching how the shell lays a multi-line command out.
blank(cell_w * (ccol as f32)).into_any_element(),
]];
// Ghost suggestion only makes sense for a single-line command (it completes
// the whole history entry); suppress it once the buffer holds a newline.
let is_multiline = chars.contains(&'\n');
for i in 0..len {
// IME pre-edit shows underlined at the caret; the bar caret is hidden
// while composing.
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' {
// The newline is a hard break, not a glyph. If the caret sits on it
// (end of this visual line) draw a trailing caret slot before the
// break so it stays visible; if the newline falls inside a selection
// draw a thin selected slot so a multi-line selection reads across
// the break. Then start the next row.
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));
}
// Ghost autosuggestion remainder (only when caret is at the end, no
// selection / IME / newline), computed up front so the end-of-line caret can
// ride on the first ghost cell instead of needing its own (which would push
// the ghost a full cell to the right).
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
};
// Caret / pre-edit at the end of the buffer — lands on the last row (a
// fresh empty row when the buffer ends in a newline).
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() {
// No ghost following: a trailing cell carries the caret (and is the
// click target for "end of line").
let mut tail = blank(cell_w).relative();
if selection.is_none() && cursor_on {
tail = tail.child(caret_bar());
}
last.push(tail.into_any_element());
}
// else: the caret rides on the first ghost cell below.
}
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()
// Transparent: the text overlays the grid in place, reading as a
// natural continuation of the shell prompt rather than a separate bar.
.font_family(self.font.family.clone())
.text_size(self.font_size)
.line_height(lh)
.text_color(fg)
.children(rows)
}
/// The floating completion menu, shown below the word while a completion is
/// active. Renders the re-filtered candidates with the picked row
/// highlighted; the list is capped with a "+N more" footer so a huge match
/// set stays compact.
fn render_completion_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
let s = self.completion.as_ref()?;
// The re-filtered view of the candidates; refilter() closes the session
// before this can go empty, but guard anyway.
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()?;
// Anchor rows are *visual*: when the overflowing input shifts the whole
// surface up (`input_scroll_rows`), the menu must follow the shifted
// input row, not the unshifted grid row.
let srow = srow.saturating_sub(self.input_scroll_rows());
// Decide how many rows to show and whether to drop the menu below the input
// row or flip it above — based on the room actually available in the grid,
// so a prompt near the bottom of the window doesn't push the menu off
// screen. The window-around-the-selection keeps the highlighted candidate
// visible even when the full list is taller than the space.
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();
// Each row is forced to exactly `line_height` so the `menu_h` estimate
// below is exact — critical for upward placement, where an underestimate
// would let the menu's real bottom edge cover the input line.
let lh = self.line_height;
let row = |i: usize| {
let cand = items[i];
let selected = s.index == Some(i);
// Leading icon: the Fig spec's per-entry icon when present (emoji
// rendered as-is, `fig://icon?type=…` mapped to a bundled glyph),
// else a per-kind default. Glyphs stay monochrome (muted, like the
// tab strip); emoji keep their own color.
let icon_color = if selected {
theme.foreground
} else {
theme.muted_foreground
};
let icon = completion_row_icon(cand.icon.as_deref(), cand.kind, icon_color);
// Directories show their trailing `/` in the menu too.
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()
// Use the app-tuned `list_active` fill (same as the command
// palette) rather than the stock `accent`: `apply_theme` never
// overrides `accent`, so in light mode it stays a near-white
// `neutral-100` that vanishes against the white popover — the
// selection looked unhighlighted. `list_active` is a per-theme
// bg/fg blend that reads clearly in both light and dark.
.when(selected, |d| {
d.bg(theme.list_active).text_color(theme.foreground)
})
.child(icon)
.child(div().flex_shrink_0().child(label))
// Second column: the flag/subcommand description from the command
// signature — muted, sized to its content. The menu's `max_w` +
// `overflow_hidden` clip an over-long line; the name never shrinks.
.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();
// Menu height (for upward placement) = rows + any overflow footers.
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.);
// A small gap so the menu never sits flush against the input line — in
// particular, when flipped above it clears the caret instead of covering it.
let gap = px(6.);
// Anchor at the command start (the cursor cell), where the line begins.
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"))),
)
}
/// The floating Ctrl+R history menu: the ranked matches (best first) in a
/// completion-style popup anchored to the input row — matched characters
/// highlighted, the last-run time and a failure badge on the right. The
/// classic `(reverse-i-search)` prompt stays on the input row itself
/// (`render_input_bar`); this menu is the browsable view of the candidates,
/// windowed around the selection like the completion menu.
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
};
// The command in runs of matched/unmatched characters, so the
// query's hits read highlighted inside the (possibly clipped) text.
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);
// Right column: a failure badge when the last run exited non-zero,
// and how long ago that run was.
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()
// Same selection fill as the completion menu (see the note
// there on `list_active` vs the stock `accent`).
.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();
// Menu height (for upward placement) = rows + any overflow footers.
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.);
// Anchored at the line's left edge (unlike the completion menu, which
// anchors at the current word): history rows are whole commands, so
// the menu spans the input area at a fixed width — that keeps the
// right-hand metadata column vertically aligned across rows. A small
// gap keeps it clear of the input line and its caret.
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"))),
)
}
/// The one-shot "shell integration didn't engage" notice (#46): a single
/// floating line, bottom-right so it reads as a status aside rather than
/// part of the prompt. Rendered whenever set — unlike the editor overlays
/// it exists precisely because `input_active()` is false.
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),
)
}
/// Map a highlighter token kind to a theme color.
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) {
// A history record still deferred when the pane goes away (tab closed,
// window closed — possibly mid-command) is flushed rather than lost;
// it carries an exit code only if the shell had already reported back.
self.flush_pending_history();
}
}
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Editor live: adopt anything typed while it was disengaged. Held gap
// input goes straight in — the PTY never saw those bytes, so nothing
// needs a wipe. Input that did reach the PTY waits for zle to read
// (`zle_reading`) so its ^U wipe is consumed silently; the `133;B`
// that arms the flag arrives as pane output, so a render always
// follows it (Output → Wakeup → notify). Both prepend: they were
// typed before any post-engage keys already sitting in the editor.
if self.shell_owns_prompt() {
// A vi-mode (or handed-off) prompt never engages the editor:
// release held gap input to the shell's own line editor (raw, no
// typeahead record — there is no local adoption to reconcile
// against) and drop any pending record without its `^U`. Those
// bytes land on zle's line and are the shell's to keep; a record
// surviving this prompt would flush at the next editor-engaged
// prompt and resurrect long-consumed text into the editor.
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));
// The command editor lives on the terminal's own focus handle (no separate
// input widget to focus), so there's no per-frame focus routing: the
// terminal keeps focus throughout, and the editor overlay is rendered only
// while idle at the prompt.
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();
// Not gated on `input_active()`: the notice explains why the editor
// overlays are absent, so it renders exactly when they can't.
let integration_notice = self.render_integration_notice(cx);
// Captured for the right-click menu: the focus handle routes dispatched
// actions to this terminal (and lets tab/split ones bubble to the root),
// and the selection state greys out "Copy" / "Cut" when there's nothing
// selected in either the grid or the prompt editor.
let menu_focus = self.focus_handle.clone();
let has_selection = self.any_selection();
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))
// No background of its own: the window root paints the theme's
// background (solid, gradient, or image — see `Tty7App::render`),
// and default-background cells don't paint either, so it shows
// through every pane. A surface-level fill here would both hide
// gradients/images and double-composite a translucent theme's alpha.
.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| {
// A focusable child that was clicked (e.g. the search field)
// has already claimed focus via gpui's track_focus auto-focus
// and called `prevent_default`. Honor that convention and don't
// steal focus back — otherwise clicking into the search bar
// instantly bounces focus to the terminal and the field can
// never be re-entered for editing.
if window.default_prevented() {
return;
}
window.focus(&this.focus_handle, cx);
}),
)
// Files dragged from Finder (etc.) onto the terminal insert their
// shell-escaped paths like a paste. `drag_over` tints the surface so
// the drop target is obvious while a drag hovers.
.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);
}))
// Context-menu actions handled by this view; tab/split actions in the
// same menu fall through to `Tty7App`.
// Menu-dispatched copy leaves the selection up (`clear_on_copy:
// false`) — only the dual-purpose ⌃C chord has to consume it.
.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)),
)
// Find-again: step to the next / previous match. No-op when the bar is
// closed (nothing to step through).
.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)))
// Tab / Shift-Tab are claimed here (in the "Terminal" key context) so
// they reach the shell instead of triggering Root's focus navigation.
// Tab → HT (0x09); Shift-Tab → CSI Z (back-tab), the standard sequence.
// While the search field is focused it owns these keys, so propagate.
.on_action(cx.listener(|this, _: &SendTab, _w, cx| {
if this.search_focused {
cx.propagate();
} else if this.input_active() {
this.complete_tab(true, cx);
} else {
let bytes = this.tab_bytes(false);
this.send_to_pty(&bytes, cx);
}
}))
.on_action(cx.listener(|this, _: &SendBackTab, _w, cx| {
if this.search_focused {
cx.propagate();
} else if this.input_active() {
this.complete_tab(false, cx);
} else {
let bytes = this.tab_bytes(true);
this.send_to_pty(&bytes, cx);
}
}))
.child(TerminalElement::new(entity))
.children(search_bar)
.children(input_bar)
.children(completion_menu)
.children(reverse_search_menu)
.children(integration_notice)
// Right-click context menu (gpui-component PopupMenu).
.context_menu(move |menu, _window, _cx| {
// Default (26px) rows: with the flat full-bleed highlight (no
// floating pill, no inter-row gap) they read dense, not airy, and
// match the command palette's row height. A fixed min-width keeps
// the menu a consistent, intentional size instead of hugging the
// longest label (which reads ragged).
// Copy/Cut/Paste/Select All are dispatched inline (see
// `handle_cmd_shortcut`) with no registered `KeyBinding`, so the menu
// can't auto-derive their hints the way it does for the items below.
// We render the hint ourselves via `menu_row_with_hint` to keep the
// whole menu consistent, rather than register real bindings (which
// would risk the Ctrl+C SIGINT fall-through on Windows/Linux).
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")),
)
// Cut is prompt-only; it shares Copy's enablement cue rather
// than offering a row that silently does nothing on output.
.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()
// Find now has a real registered binding, so let the menu
// auto-render its shortcut hint (correct per platform) like the
// items below, instead of a hand-rolled mac-only one.
.menu("Find…", Box::new(FindInTerminal))
.menu("Clear", Box::new(ClearScrollback))
.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))
})
}
}
/// Build a context-menu row that shows its shortcut right-aligned, matching the
/// hint gpui-component auto-renders for items whose action has a registered
/// keybinding. `key` is `None` when the action has no shortcut on this platform,
/// leaving the row hint-less like a plain item.
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| {
// Strip Kbd's keycap box (filled bg + border) so it reads as the same
// quiet muted-foreground hint the auto-rendered items show — see
// gpui-component's `PopupMenu::render_key_binding`.
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()
}
}
/// `Some(key)` on macOS, `None` elsewhere. ⌘A (Select All) and ⌘F (Find) are
/// wired only on macOS; on Windows/Linux those chords keep their readline meaning
/// (line-start / forward-char), so the menu must not advertise them there.
#[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
}
/// Approximate terminal display width of a char in cells: 2 for East-Asian
/// wide / fullwidth glyphs and most emoji, 1 otherwise. Mirrors how the grid
/// (alacritty) lays out wide characters, so the editor's per-char cells and
/// click hit-testing line up with the shell's own rendering.
/// The char index where the whitespace-delimited word ending at `cursor` begins.
/// Mirrors the word-splitting the completion engine does, used when a completion
/// is all generators (no sync candidate to read the range off).
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 // Hangul Jamo
| 0x2329 | 0x232A
| 0x2E80..=0x303E // CJK radicals, Kangxi, punctuation
| 0x3041..=0x33FF // Hiragana, Katakana, CJK symbols
| 0x3400..=0x4DBF // CJK Ext A
| 0x4E00..=0x9FFF // CJK Unified
| 0xA000..=0xA4CF // Yi
| 0xAC00..=0xD7A3 // Hangul syllables
| 0xF900..=0xFAFF // CJK compatibility
| 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical / compat forms
| 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 // fullwidth forms
| 0x1F300..=0x1FAFF // emoji & pictographs
| 0x20000..=0x3FFFD // CJK Ext B+
);
if wide { 2 } else { 1 }
}
/// Where a wheel tick goes, decided by the modes the app negotiated.
#[derive(Debug, PartialEq)]
enum WheelRoute {
/// Mouse-wheel reporting: one report per scrolled line (64 up / 65 down).
Report { base: u8 },
/// Alternate scroll: the wheel becomes arrow keys (less, man).
Arrows { seq: &'static [u8] },
/// Nothing negotiated: scroll the local scrollback.
Scrollback,
}
/// Route a wheel tick. Shift always bypasses app handling (the standard
/// "scroll the terminal anyway" escape hatch), mouse reporting wins over
/// alternate scroll when both are on, and alternate scroll additionally
/// requires the *alt screen* — an app that set ALTERNATE_SCROLL but has
/// returned to the primary screen must not hijack the wheel from the
/// 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
}
/// What a finished mouse-selection gesture should auto-copy when
/// copy-on-select is enabled (see `Config::copy_on_select`).
#[derive(Debug, PartialEq)]
enum SelectEndCopy {
/// Feature off, or the mouse-up ended no selection gesture (a plain
/// click, a right/middle release): leave the clipboard alone.
None,
/// The gesture drove the terminal grid selection (drag / double / triple
/// click over output): copy `term.selection`.
Grid,
/// The gesture landed on the command editor's line: copy the editor's
/// own selection.
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());
}
}
/// Run a user-configured file-open command for a clicked file link. The template
/// is expanded by [`expand_file_command_template`] and the first token is the
/// program; the rest are its arguments. Spawned detached — tty7 doesn't wait for
/// or read from the editor it launches.
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}");
}
}
/// Expand a file-open command template into an argv vector.
///
/// The template is split on whitespace into tokens. Within a token the
/// placeholders `{path}`, `{line}`, and `{column}` are replaced with their
/// values. If a token references a placeholder whose value is absent (e.g.
/// `{line}` for a link with no line number), the whole token is dropped — this
/// lets a combined token like `--line={line}` disappear cleanly rather than
/// leaving a dangling flag. `{path}` is always present, so a token that only
/// references `{path}` is never dropped.
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()
}
/// Substitute placeholders in a single template token, or return `None` if the
/// token references a placeholder with no value (so the caller drops it).
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 {
// No closing brace: the remainder is literal text.
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()),
// An unknown placeholder is left verbatim rather than dropping the
// token, so a stray brace doesn't silently swallow an argument.
other => Some(format!("{{{other}}}")),
};
// A recognized-but-absent placeholder drops the entire token.
out.push_str(&value?);
rest = &rest[close + 1..];
}
out.push_str(rest);
Some(out)
}
/// One mouse report, encoded for the protocol the app negotiated. SGR (1006)
/// prints decimal 1-based coordinates and keeps the button in the final
/// letter (`M` press / `m` release); X10 packs everything into three bytes,
/// which caps coordinates at 223 (255 32 1) — events beyond that are
/// dropped (`None`) rather than sent corrupted — and loses the button
/// identity on release (code 3). Modifier bits (shift 4 / alt 8 / ctrl 16)
/// are added to `base` in both encodings.
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 {
// X10 encoding caps coordinates at 223 (255 - 32).
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,
])
}
}
/// The focus-event report for a focus change, when the app enabled focus
/// reporting (mode 1004): `CSI I` on gain, `CSI O` on loss, `None` when the
/// mode is off (the overwhelmingly common case — nothing reaches the PTY).
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" })
}
/// A completion row's leading icon, in a fixed-width centered slot so emoji and
/// SVG glyphs share one column. Prefers the Fig spec's `icon` (emoji rendered
/// as text, `fig://icon?type=…` mapped to a bundled glyph), falling back to a
/// per-kind default.
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(),
);
}
}
// Per-kind default: a terminal glyph for commands / subcommands / values, a
// dash for flags, folder / file for paths.
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(),
)
}
/// The emoji to render for a Fig `icon`, if it is one: a bare emoji string, or
/// the `badge` of a `fig://template?…`. `None` for a named `fig://icon?type=…`.
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
}
}
/// Map a `fig://icon?type=X` to one of tty7's bundled glyphs, or `None` to fall
/// back to the per-kind default — we ship no brand glyph for node/docker/npm/….
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,
}
}
/// Extract `key`'s value from a `fig://…?a=1&b=2` query string.
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)
})
}
/// Place the completion menu and window its rows around the selection — the
/// pure core of [`TerminalView::render_completion_menu`]. `total_rows` is the
/// grid height, `srow` the input row the menu anchors to, `count` the number of
/// candidates (≥ 1), `sel` the selected index, and `max_rows` the display cap.
/// Returns `(place_above, visible, first)`: whether the menu flips above the
/// input row (only when it doesn't fit below), how many candidate rows to show
/// (at least 1, even squeezed against an edge), and the index of the first
/// visible candidate — chosen so `sel` always lies within
/// `first..first + visible`.
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;
// The space budget must include the up-to-two "↑/↓ N more" footer lines a
// *windowed* list renders — they share the menu box with the candidate
// rows, so sizing on candidates alone let the menu (and, downward, the
// selected row riding the window's bottom edge) spill off screen.
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 {
// Cramped on both sides: take the larger side and squeeze the
// candidate rows under it, reserving the footer lines that squeezing
// (which hides candidates) makes appear. Always show at least one row.
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);
// Scroll the visible window so the selected candidate stays in view.
let first = sel
.saturating_sub(visible.saturating_sub(1))
.min(count.saturating_sub(visible));
(place_above, visible, first)
}
/// Map a click cell to a char index in the wrapped input line — the pure core
/// of [`TerminalView::editor_char_index`], simulating the layout exactly as
/// `render_input_bar` produces it: char 0 starts at column `scol` (right after
/// the prompt) of the input's first row, each char advances by its display
/// width, and a char that wouldn't fit wraps whole to column 0 of the next row.
/// `col` is the clicked column and `target` the clicked row minus the input's
/// first row. A hit on a char cell returns its index; a click left of a row's
/// first char snaps to that char; past a row's content snaps to the next row's
/// first char (or the line end). Rows beyond the input return `len` with
/// `clamp` (for drags) and `None` without (so the click isn't an editor click).
/// Visual `(row, start-col, width)` of every char in the wrapped input line,
/// matching `render_input_bar`'s layout: char 0 starts at column `scol` (right
/// after the prompt), a `'\n'` is a hard break to column 0 of the next row (and
/// occupies no cell — width 0), and within a line a char that would overflow
/// wraps whole to column 0 of the next row. Also returns the pen `(row, col)`
/// after the last char, so callers can place the trailing end-of-line caret.
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)
}
/// Visual size of the rendered input overlay: how many wrapped rows it
/// occupies and which of them carries the caret. Mirrors `render_input_bar`'s
/// layout: the IME pre-edit is inserted at the caret, and a one-cell caret
/// slot trails the buffer when the caret sits at the end (wrapping to a fresh
/// row when the content exactly fills its last one). The ghost autosuggestion
/// is deliberately excluded — the screen shouldn't scroll to reveal a
/// suggestion the user hasn't accepted.
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)
}
/// Rows the grid (and the input overlay riding on it) must shift up so the
/// wrapped command editor stays visible when the prompt sits near the bottom:
/// enough that the overlay's last row lands on the last grid row — capped so
/// the caret's row never scrolls off the top when the input is taller than
/// the whole screen.
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();
// `positions[i]` is the (row, start-col, width) of char `i`; `r`/`c` are the
// pen position after the last char.
let (positions, r, c) = input_char_positions(chars, scol, cols);
// The renderer appends a one-cell end-of-line caret slot after the last
// char; when the content exactly fills its row, that slot wraps to the next
// row (where the caret is visibly drawn), so clicks there must still count
// as "this input", not fall past it.
let end_row = if c >= cols { r + 1 } else { r };
if target > end_row {
return clamp.then_some(len);
}
// Exact hit on a char cell.
for (i, &(pr, pc, pw)) in positions.iter().enumerate() {
if pr == target && col >= pc && col < pc + pw {
return Some(i);
}
}
// Click on the row but left of its first char.
if let Some(fi) = positions.iter().position(|&(pr, _, _)| pr == target) {
if col < positions[fi].1 {
return Some(fi);
}
}
// Past the row's content. If the row ends at a hard line break, snap to that
// newline — the end of this logical line — rather than jumping onto the next
// line. (A soft-wrapped row has no newline, so it continues below.)
if let Some(last) = positions.iter().rposition(|&(pr, _, _)| pr == target) {
if chars[last] == '\n' {
return Some(last);
}
}
// Otherwise the line soft-wraps: snap to the first char of the next visual
// row, or the buffer end.
match positions.iter().position(|&(pr, _, _)| pr > target) {
Some(ni) => Some(ni),
None => Some(len),
}
}
/// Advance the continuous scroll position `offset + frac` (in lines, 0 =
/// bottom, growing into history) by `delta` lines, clamped to `[0, max]`.
/// Returns the whole-line jump to hand to the emulator's `display_offset`
/// and the new sub-line fraction in `[0, 1)`.
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)
}
/// Lines to scroll per auto-scroll tick for a selection drag sitting
/// `overshoot` lines past the pane edge (sign = direction, positive = up into
/// history). At least one line per tick so grazing the edge still crawls;
/// farther out speeds up, capped so a wild fling stays controllable
/// (8 lines/tick at a 50ms cadence ≈ 160 lines/s).
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::{
SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, 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};
#[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() {
// No line/column: the combined flag tokens vanish entirely, leaving no
// dangling `--line` for the downstream parser.
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"]);
// Column absent but line present: only the column flag drops.
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() {
// A path-only program still runs; an unknown placeholder is left verbatim
// rather than dropping its token.
let argv = expand_file_command_template(
"code --goto {path}:{line} {other}",
Path::new("/tmp/foo.rs"),
None,
None,
);
// `{path}:{line}` drops (line absent); `{other}` stays literal.
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};
// A BMP (what a Windows screenshot lands as) must be re-encoded to PNG,
// since agent vision rejects BMP. Build one with the image crate.
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");
// PNG magic number: the staged file is genuinely a PNG, not renamed BMP.
assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
// A format agents already accept is written through byte-for-byte.
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);
}
/// The bundled Hack always anchors the fallback chain so prompt symbols
/// (`➜`, ``, powerline wedges) never fall through to the OS cascade —
/// unless the user already covers it as primary or in their own list.
#[test]
fn fallback_chain_pins_bundled_hack_last() {
let configured = vec!["Menlo".to_string(), "Apple Color Emoji".to_string()];
// A custom primary that may lack the prompt symbols → Hack appended.
let chain = fallback_chain("JetBrains Mono", &configured);
assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]);
assert_eq!(chain.last().unwrap(), "Hack");
// Hack as the primary face already covers everything it could add.
let chain = fallback_chain("Hack", &configured);
assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]);
assert!(!chain.iter().any(|f| f == "Hack"));
// A user who lists Hack explicitly keeps their chosen position.
let with_hack = vec!["Hack".to_string(), "Menlo".to_string()];
let chain = fallback_chain("SF Mono", &with_hack);
assert_eq!(chain[..2], ["Hack", "Menlo"]);
// "Hack Nerd Font" is a different family — the bundled face still lands.
assert_eq!(
fallback_chain("Hack Nerd Font", &[]).last().unwrap(),
"Hack",
"a Hack-prefixed family name must not suppress the bundled anchor"
);
}
/// Hack has no ideographs, so a chain naming only faces this OS lacks sends
/// every CJK glyph into the platform cascade — where a 1.0em face gets
/// left-aligned inside `element.rs`'s 1.2041em two-column slot. The stock
/// names have to be in the chain even for a config written before the fix.
#[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");
// The pre-fix default: macOS-only names, nothing Windows/Linux can match.
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:?}"
);
}
// The user's own order is never displaced — stock faces land behind it.
assert_eq!(chain[..legacy.len()], legacy[..]);
// Already-listed stock faces aren't duplicated.
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:?}"
);
// A stock face chosen as the *primary* isn't re-added as its own fallback.
assert!(!fallback_chain(stock[0], &[]).iter().any(|f| f == stock[0]));
}
/// The wheel reaches the app only through the modes it negotiated: mouse
/// reporting first, alternate scroll second, local scrollback otherwise.
#[test]
fn wheel_routes_by_negotiated_mode_with_reporting_first() {
// Any mouse mode → per-line reports, 64 up / 65 down.
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 }
);
// Alt screen + alternate scroll (less, man) → arrow keys, and the
// cursor-keys mode picks between CSI and SS3 encodings.
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" }
);
// Both negotiated (vim with mouse on) → reporting wins.
assert_eq!(
wheel_route(mouse | alt, false, true),
WheelRoute::Report { base: 64 }
);
// Nothing negotiated → local scrollback.
assert_eq!(
wheel_route(TermMode::empty(), false, true),
WheelRoute::Scrollback
);
}
/// ALTERNATE_SCROLL without the alt screen must NOT hijack the wheel:
/// after `less` exits back to the primary screen with the mode bit still
/// set, the wheel has to scroll the terminal's own history again.
#[test]
fn wheel_ignores_alternate_scroll_outside_the_alt_screen() {
assert_eq!(
wheel_route(TermMode::ALTERNATE_SCROLL, false, true),
WheelRoute::Scrollback
);
}
/// Shift is the universal "scroll the terminal anyway" escape hatch — it
/// bypasses both mouse reporting and alternate scroll.
#[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);
}
/// Copy-on-select fires only when the released gesture actually drove a
/// selection, and copies the buffer that gesture touched — the terminal
/// grid or the command editor's line. Off, or a mouse-up that ended no
/// gesture (a plain click, a right-click), must leave the clipboard alone.
#[test]
fn copy_on_select_copies_the_buffer_the_gesture_touched() {
// Disabled: never copy, whatever kind of gesture just ended.
assert_eq!(select_end_copy(false, true, false), SelectEndCopy::None);
assert_eq!(select_end_copy(false, false, true), SelectEndCopy::None);
// A grid gesture (drag / double / triple click over output) copies
// the terminal selection; one on the editor line copies the editor's.
assert_eq!(select_end_copy(true, true, false), SelectEndCopy::Grid);
assert_eq!(select_end_copy(true, false, true), SelectEndCopy::Editor);
// No gesture ended → nothing to copy.
assert_eq!(select_end_copy(true, false, false), SelectEndCopy::None);
// The press routes to exactly one buffer, but if both flags ever
// read set, the grid selection (the visible one) wins.
assert_eq!(select_end_copy(true, true, true), SelectEndCopy::Grid);
}
/// SGR (1006) reports print 1-based decimal coordinates, stack the
/// modifier bits onto the button code, and carry press/release in the
/// final letter. A drift in any of these lands clicks one cell off in
/// vim/tmux.
#[test]
fn sgr_mouse_reports_one_based_decimal_with_modifier_bits() {
let plain = Modifiers::default();
// Left press at 0-based (col 4, row 8) → "5;9", press = 'M'.
assert_eq!(
encode_mouse(true, 0, &plain, 4, 8, true).unwrap(),
b"\x1b[<0;5;9M".to_vec()
);
// Release keeps the button identity (unlike X10) and flips to 'm'.
assert_eq!(
encode_mouse(true, 2, &plain, 4, 8, false).unwrap(),
b"\x1b[<2;5;9m".to_vec()
);
// shift 4 + alt 8 + ctrl 16 = 28 on top of the base code.
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()
);
// Wheel (64/65) and drag-motion (32+) codes ride the same path.
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()
);
}
/// SGR exists precisely because X10 tops out at 223 — clicks on a wide
/// terminal past that column must still encode, not drop or wrap.
#[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()
);
}
/// X10 packs the code and both coordinates into single bytes offset by
/// 32 (+1 for 1-based), loses the button identity on release (code 3),
/// and takes the same modifier bits.
#[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]
);
// Any button's release encodes as code 3 — X10 can't say which.
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]
);
}
/// X10's byte packing can't express coordinates past 223 (255 32); the
/// event must be dropped whole — a wrapped byte would teleport the click
/// to the far side of the grid.
#[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());
// The last representable cell still encodes, right at byte 255.
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() {
// A bare emoji renders as-is.
assert_eq!(fig_icon_emoji("⚙️"), Some("⚙️"));
// A colored template contributes its badge emoji.
assert_eq!(
fig_icon_emoji("fig://template?color=2ecc71&badge=🔥"),
Some("🔥")
);
// A named glyph icon is not an emoji (it maps to an SVG instead).
assert_eq!(fig_icon_emoji("fig://icon?type=git"), None);
// A badge-less template has no emoji to show.
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() {
// `IconName` is neither `PartialEq` nor `Debug`, so match on the variant.
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)
));
// No bundled brand glyph → fall back to the per-kind default.
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() {
// Mode 1004 off (the default): no bytes reach the PTY on focus changes.
assert_eq!(focus_report_bytes(TermMode::empty(), true), None);
assert_eq!(focus_report_bytes(TermMode::empty(), false), None);
// Opted in: CSI I on gain, CSI O on loss — what vim/tmux key off.
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()));
// Unrelated modes don't leak reports.
assert_eq!(focus_report_bytes(TermMode::MOUSE_MOTION, true), None);
}
#[test]
fn smooth_scroll_step_accumulates_and_clamps() {
// Sub-line deltas accumulate in the fraction without moving the grid.
assert_eq!(smooth_scroll_step(0, 0.0, 0.4, 100), (0, 0.4));
// Crossing a line boundary hands the whole line to the emulator and
// keeps the remainder.
let (jump, frac) = smooth_scroll_step(0, 0.4, 0.8, 100);
assert_eq!(jump, 1);
assert!((frac - 0.2).abs() < 1e-4);
// Scrolling back down borrows from the offset.
let (jump, frac) = smooth_scroll_step(5, 0.2, -0.5, 100);
assert_eq!(jump, -1);
assert!((frac - 0.7).abs() < 1e-4);
// The bottom clamps to exactly (0, 0): no fraction survives.
assert_eq!(smooth_scroll_step(3, 0.5, -10.0, 100), (-3, 0.0));
// The top of history clamps to (max, 0) likewise.
assert_eq!(smooth_scroll_step(98, 0.0, 7.3, 100), (2, 0.0));
// No history at all (alt screen / fresh shell): position is pinned.
assert_eq!(smooth_scroll_step(0, 0.0, 2.5, 0), (0, 0.0));
}
/// Selection auto-scroll: grazing the edge crawls one line per tick,
/// farther out speeds up with the overshoot, a fling caps at 8, and the
/// sign follows the direction (positive = up into history).
#[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() {
// Trailing spaces/tabs go; interior spaces and line count stay.
assert_eq!(trim_trailing_spaces("a \nb\t\nc"), "a\nb\nc");
// A trailing newline round-trips (no line gained or lost).
assert_eq!(trim_trailing_spaces("a \n"), "a\n");
// No trailing newline stays that way.
assert_eq!(trim_trailing_spaces("a "), "a");
// Leading whitespace is untouched.
assert_eq!(trim_trailing_spaces(" a "), " a");
}
#[test]
fn paste_bytes_strips_esc_to_prevent_bracketed_paste_escape() {
// A benign paste is wrapped verbatim between the bracketed-paste markers.
assert_eq!(
paste_bytes("ls -la", true),
b"\x1b[200~ls -la\x1b[201~".to_vec()
);
// Malicious clipboard text carrying its own `ESC[201~` end-marker followed
// by a newline + command: without stripping ESC this would break out of the
// paste and run `rm -rf ~` as typed input. The fix strips every ESC so the
// smuggled end-marker becomes inert.
let evil = "foo\x1b[201~\nrm -rf ~\n";
let out = paste_bytes(evil, true);
let end = b"\x1b[201~";
// Exactly one end-marker survives — the trusted one we append, not the
// smuggled one (an unfiltered impl would leave two).
let markers = out.windows(end.len()).filter(|w| *w == end).count();
assert_eq!(markers, 1);
// No raw ESC remains inside the wrapped payload.
let inner = &out[b"\x1b[200~".len()..out.len() - end.len()];
assert!(!inner.contains(&0x1b));
// Visible characters are preserved; only the ESC bytes are dropped.
assert_eq!(inner, b"foo[201~\nrm -rf ~\n");
// Without bracketed paste there is no wrapping, so bytes pass through as-is.
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() {
// Regression: a raw-mode app (the only consumer of the non-bracketed
// PTY path) reads keys, and Enter is CR — pasted `\n`/`\r\n` must
// arrive as `\r`, matching xterm/alacritty, or apps that bind
// accept/submit to CR only mis-handle multi-line pastes.
assert_eq!(paste_bytes("a\nb\r\nc\n", false), b"a\rb\rc\r".to_vec());
// Under bracketed paste the receiver gets the text verbatim (minus
// ESC): the markers make line handling the app's own business.
assert_eq!(
paste_bytes("a\nb", true),
b"\x1b[200~a\nb\x1b[201~".to_vec()
);
}
#[test]
fn submit_bytes_sends_a_multi_line_command_as_one_bracketed_paste() {
// The regression this exists for: replaying each newline as its own CR
// made zle run a full prompt cycle per line (preexec + the user's
// precmd chain + a highlight pass), so a pasted block crawled down the
// screen. One paste, one CR — one cycle, whatever the line count.
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()
);
// Exactly one CR reaches the shell: the accept, not one per line.
let out = submit_bytes("a\nb\nc\nd", true);
assert_eq!(out.iter().filter(|&&b| b == b'\r').count(), 1);
// Single-line commands take the same shape — no special case.
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() {
// A shell that never enabled bracketed paste can only assemble a
// multi-line command the old way: one Enter per line, letting its
// editor do the PS2 continuation.
assert_eq!(submit_bytes("a\nb", false), b"a\rb\r".to_vec());
// A CRLF clipboard yields one CR per line, not a stray extra Enter
// (`\r\n` used to become `\r\r` — a blank line submitted mid-command).
assert_eq!(submit_bytes("a\r\nb", false), b"a\rb\r".to_vec());
}
#[test]
fn submit_bytes_normalizes_line_breaks_inside_the_paste() {
// The CR of a CRLF clipboard must not ride inside the markers either:
// zsh turns a pasted CR into a newline (so the block would gain a blank
// line) and a shell that doesn't leaves a literal `^M` in the command.
assert_eq!(
submit_bytes("a\r\nb", true),
b"\x1b[200~a\nb\x1b[201~\r".to_vec()
);
// A lone CR is a line break too — dropping it would glue the lines
// together into one command.
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() {
// Clipboard text carrying its own `ESC[201~` would otherwise close the
// paste early and have the rest run as typed input.
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());
// ESC is stripped on the unbracketed path too — raw ESC reaching zle is
// an editor command, not text.
assert_eq!(submit_bytes("a\x1bb", false), b"ab\r".to_vec());
// An empty line is a bare Enter: zsh's `bracketed-paste-magic` errors
// on a paste with nothing between the markers.
assert_eq!(submit_bytes("", true), b"\r".to_vec());
}
#[test]
fn shell_escape_path_escapes_spaces_and_metachars() {
// A plain path is untouched.
assert_eq!(
shell_escape_path("/Users/me/notes.txt"),
"/Users/me/notes.txt"
);
// Spaces and shell metacharacters each gain a backslash so the whole
// path reaches the shell as a single argument.
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"
);
// Empty becomes an explicit empty-string literal.
assert_eq!(shell_escape_path(""), "''");
// A newline can't be backslash-escaped, so the path is single-quoted.
assert_eq!(shell_escape_path("a\nb"), "'a\nb'");
}
#[test]
fn clipboard_paste_text_escapes_and_space_joins_files() {
// Finder-style file copy: paths are escaped and space-joined — not glued
// together like gpui's `text()` fallback, and not left raw.
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")
);
// Plain text still passes through verbatim.
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); // CJK Unified
assert_eq!(display_width('한'), 2); // Hangul syllable
assert_eq!(display_width('あ'), 2); // Hiragana
assert_eq!(display_width(' '), 2); // fullwidth space (U+3000)
}
#[test]
fn display_width_emoji_are_wide() {
assert_eq!(display_width('🚀'), 2); // U+1F680, in emoji range
assert_eq!(display_width('🎉'), 2);
}
#[test]
fn display_width_latin_accents_stay_narrow() {
// Accented Latin and common symbols outside the wide ranges are 1 cell.
assert_eq!(display_width('é'), 1);
assert_eq!(display_width('©'), 1);
assert_eq!(display_width('±'), 1);
}
/// Shorthand: run `wrapped_click_index` over `text`'s chars.
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() {
// Prompt ends at column 4; "git" occupies columns 4..7 of row 0.
assert_eq!(click("git", 4, 80, 4, 0), Some(0));
assert_eq!(click("git", 4, 80, 6, 0), Some(2));
// Left of the first char (on the prompt itself) snaps to char 0.
assert_eq!(click("git", 4, 80, 1, 0), Some(0));
// Past the row's content → end of line.
assert_eq!(click("git", 4, 80, 40, 0), Some(3));
}
#[test]
fn wrapped_click_index_maps_wrapped_rows() {
// 10-column grid, prompt at column 8: "abcdef" lays out as row 0 =
// "ab" (cols 8..10), row 1 = "cdef" (cols 0..4).
assert_eq!(click("abcdef", 8, 10, 9, 0), Some(1)); // 'b'
assert_eq!(click("abcdef", 8, 10, 0, 1), Some(2)); // 'c'
assert_eq!(click("abcdef", 8, 10, 3, 1), Some(5)); // 'f'
// A wide char that can't fit the row's last cell wraps whole, leaving a
// dead cell at the row end; clicking it snaps to the wrapped char —
// "a你" on a 4-col grid: 'a' at (0,2), dead cell (0,3), 你 at (1,0..2).
assert_eq!(click("a你", 2, 4, 3, 0), Some(1));
// Past the last row's content → end of line.
assert_eq!(click("abcdef", 8, 10, 9, 1), Some(6));
}
#[test]
fn wrapped_click_index_respects_wide_chars() {
// "你好" after a 2-col prompt: 你 covers cols 2..4, 好 covers 4..6 —
// either cell of a wide glyph resolves to its char index.
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));
// A wide char that doesn't fit in the row's last cell wraps whole: on a
// 5-col grid with the prompt at column 4, 你 moves to row 1 cols 0..2.
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();
// A click two rows below a one-row input isn't an editor click…
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, false), None);
// …but a drag (clamp) snaps to the end of the line.
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, true), Some(2));
// An empty line: any column of the input row maps to index 0.
assert_eq!(wrapped_click_index(&[], 4, 80, 30, 0, false), Some(0));
// One row below a one-row input that doesn't fill its row stays None
// (there is no caret slot down there).
assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 1, false), None);
}
#[test]
fn wrapped_click_index_covers_the_wrapped_caret_slot() {
// Regression: "abcdef" after a 4-col prompt exactly fills a 10-col row,
// so the renderer's end-of-line caret slot wraps to row 1 col 0 — the
// blinking caret is visibly drawn there. A click on that row must map
// to the end of the line, not fall off the input (which turned the
// click into a terminal selection instead of a caret move).
assert_eq!(click("abcdef", 4, 10, 0, 1), Some(6));
assert_eq!(click("abcdef", 4, 10, 7, 1), Some(6));
// Two rows down is still past the input.
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() {
// "a\nbc" after a 4-col prompt lays out as row 0 = "a" (col 4) and
// row 1 = "bc" (cols 0..2). Indices: 0='a', 1='\n', 2='b', 3='c'.
assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0)); // 'a'
assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2)); // 'b' on the next line
assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3)); // 'c'
// Clicking past the end of the first line snaps to the newline (the end
// of that logical line), not onto the second line.
assert_eq!(click("a\nbc", 4, 80, 40, 0), Some(1));
// Past the last line's content → buffer end.
assert_eq!(click("a\nbc", 4, 80, 40, 1), Some(4));
// A blank line in the middle ("a\n\nb") is its own row; clicking it lands
// on that empty line rather than falling through to "b".
// Indices: 0='a', 1='\n', 2='\n', 3='b'. Row 1 holds the second newline.
assert_eq!(click("a\n\nb", 4, 80, 3, 1), Some(2));
assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3)); // 'b' on row 2
}
#[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)
};
// Empty input: just the caret slot on the prompt row.
assert_eq!(rows("", 0, "", 3, 8), (1, 0));
// 10 chars after a 6-col prompt in an 8-col grid fill rows 0..=1
// exactly, so the end-of-line caret slot wraps to row 2.
assert_eq!(rows("aaaaaaaaaa", 10, "", 6, 8), (3, 2));
// Same content with the caret in the middle: no trailing slot beyond
// the content, and the caret sits on the char's own row.
assert_eq!(rows("aaaaaaaaaa", 3, "", 6, 8), (2, 1));
// A hard newline is its own break; caret at the end lands on row 1.
assert_eq!(rows("ab\ncd", 5, "", 0, 8), (2, 1));
// IME pre-edit is inserted at the caret and counts its display width:
// the two-cell 漢 doesn't fit in the last column of row 0, so it wraps
// whole — pulling the caret's row down with it.
assert_eq!(rows("ab", 1, "漢", 6, 8), (2, 1));
}
#[test]
fn input_overflow_shift_keeps_the_tail_and_caret_visible() {
// Fits: a 3-row input anchored at row 5 of a 22-row grid.
assert_eq!(input_overflow_shift(5, 2, 3, 22), 0);
// Spills one row past the bottom → shift up by one.
assert_eq!(input_overflow_shift(20, 2, 3, 22), 1);
// Taller than the whole screen, caret at the end: shift so the last
// row lands on the last grid row (caret stays visible with it).
assert_eq!(input_overflow_shift(21, 29, 30, 22), 29);
// Same giant input with the caret back on its first row: the cap
// stops the caret row from scrolling off the top.
assert_eq!(input_overflow_shift(21, 0, 30, 22), 21);
}
#[test]
fn menu_layout_prefers_below_and_flips_above_when_cramped() {
// Plenty of room below: all 5 rows drop under the input row.
assert_eq!(menu_layout(24, 3, 5, 0, 10), (false, 5, 0));
// Input near the bottom: not enough room below, plenty above → flip.
assert_eq!(menu_layout(24, 22, 5, 0, 10), (true, 5, 0));
// Cramped on both sides: the larger side wins, squeezed to what fits
// *including* the footer lines squeezing makes appear.
assert_eq!(menu_layout(6, 4, 10, 0, 10), (true, 2, 0));
assert_eq!(menu_layout(6, 1, 10, 0, 10), (false, 2, 0));
// Even a 1-row grid shows at least one candidate row.
let (_, visible, _) = menu_layout(1, 0, 8, 0, 10);
assert_eq!(visible, 1);
}
#[test]
fn menu_layout_budgets_the_overflow_footers() {
// Regression: a windowed list renders up to two "N more" footer lines
// in the same box. Sizing on candidate rows alone placed a 12-line menu
// (10 rows + 2 footers) into 10 free rows below — clipping the last two
// lines, one of which held the *selected* candidate (the window pins the
// selection to its bottom edge). The budget must count the footers, so
// this case flips above where all 12 lines fit.
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);
// The selection stays within the visible window.
assert!((first..first + visible).contains(&17));
}
#[test]
fn menu_layout_caps_rows_and_windows_around_the_selection() {
// 30 candidates cap at max_rows; selecting deep into the list scrolls
// the window so the selection sits on its last visible row.
let (_, visible, first) = menu_layout(40, 0, 30, 17, 10);
assert_eq!(visible, 10);
assert!((first..first + visible).contains(&17));
assert_eq!(first, 8); // sel rides the window's bottom edge
// Selecting the last candidate clamps the window to the list's tail.
let (_, visible, first) = menu_layout(40, 0, 30, 29, 10);
assert_eq!(first, 20);
assert_eq!(first + visible, 30);
// A selection inside the first window leaves it unscrolled.
assert_eq!(menu_layout(40, 0, 30, 3, 10).2, 0);
}
}
/// gpui-harness tests: a real (headless) App + Window around a `TerminalView`
/// wired to a socketpair, so `handle_event` and the event pump run exactly as
/// in production. The test plays the daemon on the other end of the socket —
/// write `DaemonMsg`s to feed the terminal, read `ClientMsg`s to observe what
/// the view sent back.
#[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) {
// The terminal's reader is a real OS thread feeding a real socket, so
// this test mixes deterministic scheduling with outside I/O — exactly
// what `allow_parking` exists for.
cx.executor().allow_parking();
let (client_side, daemon_side) = UnixStream::pair().unwrap();
cx.update(|cx| {
// Same globals `main` installs: the component theme (view code
// reads it via `cx.theme()`) and the user config.
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)
}
/// A hover cell remembered while the pane was tall names a row the grid no
/// longer has once the pane shrinks (a vertical split, a smaller window).
/// Resolving it must decline rather than index the grid — this path runs
/// from `ModifiersChanged` (the ⌘ of the very ⌘⇧D that split the pane), an
/// `extern "C"` callback where the panic can't unwind and aborts the app.
#[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| {
// Hover the last row of the 24-row grid, then shrink to 8 rows.
view.hover_link_at(0, 23, true, cx);
view.terminal.resize(TermSize::new(80, 8), 8, 17);
// `set_grid_size` drops the stale cell in the real app; pin it
// here so the guard inside the lookup is what's under test.
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();
}
/// The other half of the fix: the pane that shrank forgets the hover it was
/// holding, rather than carrying a cell (and the underline it resolved) that
/// now names different text.
#[gpui::test]
fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
// Pin a known geometry first — what the test window measured for
// itself is the element's business, and this is about the
// transition. Then hover the last row of those 24.
view.set_grid_size(80, 24, px(8.), px(17.));
view.hover_link_at(0, 23, true, cx);
assert_eq!(view.last_hover_cell, Some((0, 23)));
view.hovered_link = Some(HoveredLink {
line: 23,
start: 0,
end: 3,
});
// The same geometry again changes nothing...
view.set_grid_size(80, 24, px(8.), px(17.));
assert_eq!(view.last_hover_cell, Some((0, 23)));
// ...but a split (or a window drag) that shrinks the pane does.
view.set_grid_size(80, 8, px(8.), px(17.));
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();
}
/// The first frames out of the socket may be `Resize`s — the headless
/// window really lays the element out, and the first prepaint syncs its
/// measured geometry. Skip to the next `Input`.
fn next_input(daemon: &mut UnixStream) -> Vec<u8> {
loop {
match ClientMsg::read(daemon).expect("client socket stays open") {
ClientMsg::Input(bytes) => return bytes,
_ => continue,
}
}
}
/// Deliver one printable character the way the running platform actually
/// does. macOS hands all text to the input context, which arrives as
/// `commit_text` (see `input::defer_to_ime`); elsewhere it travels the
/// `on_key_down` / `key_char` path. Tests that assert on *text* input must
/// go through here, or they exercise a path the platform never takes.
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");
}
/// Wait until the daemon-fed prompt state makes the local editor live.
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");
}
/// Tab the engine has nothing for must not be swallowed (#136): the
/// locally edited line is shipped to the shell followed by the Tab
/// itself, and the local editor stays out of the way until the shell
/// reports its next prompt — from there the shell's own completion owns
/// the line.
#[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| {
// A command-position word matching no builtin or $PATH entry,
// so the completion engine returns `None`.
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"
);
// A same-prompt redraw (a prompt framework re-emitting the
// PS1-embedded `133;B` on reset-prompt / a completion list reprint)
// must NOT re-engage the editor — zle still holds the handed-off
// text, and an engaged-empty editor would fork the two buffers.
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();
// A real command cycle — the shell leaves the prompt and comes back —
// re-engages the local editor.
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);
}
/// With `tab_completion` off, Tab never opens tty7's menu — even when the
/// engine would have candidates, the line and the Tab go to the shell.
#[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| {
// "cd " would offer path candidates were the engine consulted.
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"
);
}
/// Text typed during a command gap is held for the next prompt's editor —
/// but a vi prompt never engages the editor, so the hold must be released
/// raw (the shell's own line editor consumes it) and the typeahead record
/// dropped. Without that, the record lingers past the whole vi prompt and
/// flushes at the next emacs-mode prompt: a spurious `^U` plus the long-
/// consumed gap text resurrected into the local editor.
#[gpui::test]
fn shell_vi_mode_prompt_releases_gap_hold_without_stale_typeahead(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// Shell integration live, a command running: gap input gets held.
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();
// The command finishes into a vi-mode prompt.
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));
}
// Fire any pending hold-window timer too, so both release paths are
// covered regardless of which one runs first.
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"
);
// Back to an emacs-mode prompt: the editor re-engages empty-handed.
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")
}
/// The notice names only known fig-style shims — an ordinary foreground
/// command (`ssh`) must not be blamed for intercepting anything, and the
/// generic message must not claim interception it can't prove.
#[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"));
}
/// The Ctrl+R integration notice (#46), through the real key dispatcher:
/// silent inside the startup grace window, raised once integration has had
/// time to engage and never did, dismissed by the next keystroke, and
/// one-shot per pane. The chord itself still reaches the PTY throughout
/// (the shell's own reverse-i-search is the fallback).
#[gpui::test]
fn ctrl_r_without_integration_raises_the_notice_once(cx: &mut TestAppContext) {
// `note_integration_gap` queries the daemon for the pane's foreground
// process; pin the config dir to a scratch so the control connection
// fails cleanly instead of reaching a real user daemon.
let dir = std::env::temp_dir().join(format!("tty7-noticetest-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
crate::core::config::set_config_dir(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,
};
// Fresh pane: the shell may legitimately not have reported yet.
view.on_key_down(&ctrl_r, window, cx);
assert!(
view.integration_notice.is_none(),
"the grace window stays silent"
);
// Past the grace window with no OSC 133 ever seen → notice.
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();
// Let the notified frame actually draw — a panic in the notice layout
// fails the test here.
cx.run_until_parked();
window
.update(cx, |view, window, cx| {
assert!(
view.integration_notice.is_some(),
"the notice survives a real render pass"
);
// The next keystroke dismisses it; the latch keeps it one-shot.
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();
}
/// The Ctrl+R flow end-to-end at the editor dispatcher: Ctrl+R opens the
/// search, typed text (the IME/commit path) edits the query with fuzzy
/// matching, Enter loads the selection into the editor without running it.
#[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");
// `gst` is a subsequence of `git status` — fuzzy, not substring.
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();
}
/// Repeated Ctrl+R steps down the ranked matches, and Cmd+Enter runs the
/// selection outright: the line must come out of the client socket as
/// `Input` bytes ending in `\r`.
#[gpui::test]
fn ctrl_r_steps_matches_and_cmd_enter_runs(cx: &mut TestAppContext) {
// `submit_command` defers a history-file record; pin the config dir to
// the shared test scratch so nothing touches the real user history.
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);
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);
// Equal fuzzy scores: the newer entry ranks first; a second
// Ctrl+R steps to the older match.
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"
);
}
/// Ctrl+J and Ctrl+M are accept-line's control codes, so at the prompt they
/// must submit exactly as Enter does (#163) — before the fix they fell into
/// `apply_readline_ctrl`'s no-op arm and the key did nothing at all.
#[gpui::test]
fn ctrl_j_and_ctrl_m_submit_the_line_like_enter(cx: &mut TestAppContext) {
// `submit_command` defers a history-file record; pin the config dir to
// the shared test scratch so nothing touches the real user history.
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);
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"
);
}
}
/// With `history_search` off, Ctrl+R never opens tty7's menu: the edited
/// line is handed to the shell and the raw `^R` follows it, so a user's own
/// binding there (fzf, percol, plain reverse-i-search) answers (#163).
#[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"
);
}
/// The Ctrl+R menu actually renders while the shell sits at its prompt:
/// with `input_active` true and a search open over entries carrying run
/// metadata, a real (headless) frame draws `render_reverse_search_menu` —
/// guarding the row/highlight/badge layout code against panics that unit
/// tests of the search logic can't reach.
#[gpui::test]
fn reverse_search_menu_survives_a_real_render_pass(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// Put the shell at its prompt so `input_active()` is true and the
// menu branch of `render` runs.
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()];
// Metadata for the badge/ago column: one failed run, one aged.
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();
// Let the notified frame actually draw — a panic in the menu layout
// or row rendering fails the test here.
cx.run_until_parked();
window
.update(cx, |view, _, _| {
assert!(view.reverse_search.is_some(), "search survives the frame");
})
.unwrap();
}
/// The deferred history record picks up the command's exit code once the
/// shell reports back at its prompt (OSC 133;D → daemon `Prompt` frame →
/// `prompt_seq`/`last_exit_code`), and the file line carries it.
#[gpui::test]
fn submitted_command_backfills_its_exit_code(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.clone());
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}");
};
// The shell reaches its prompt (integration active).
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();
// The command runs (leaves the prompt) and finishes with exit 3.
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();
// The file record is the current format with the exit code attached.
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");
}
/// Readline's Meta word chords act on the local prompt editor: M-b / M-f
/// move by word, M-d deletes the word right of the caret. (On macOS these
/// chords reach the editor only with `macos_option_as_alt` on — the
/// `on_key_down` reshape otherwise strips the alt bit; here we drive the
/// editor dispatcher directly with the post-reshape keystroke.)
#[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");
// M-b from the end lands at the start of "hello".
view.handle_editor_key(&meta("b"), cx);
assert_eq!(view.cmd.cursor(), 5);
// M-d deletes the word right of the caret.
view.handle_editor_key(&meta("d"), cx);
assert_eq!(view.cmd.text(), "echo ");
// M-b / M-f hop the remaining word: back to its start, then
// forward to its end.
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);
// Other Meta letters stay swallowed no-ops (line untouched).
view.handle_editor_key(&meta("z"), cx);
assert_eq!(view.cmd.text(), "echo ");
assert_eq!(view.cmd.cursor(), 4);
})
.unwrap();
}
/// A `PtyWrite` raised by the VT layer (query replies, bracketed-paste
/// wrapping…) must come out of the client socket as an `Input` frame —
/// this is the half of the query round-trip the remote tests can't see.
#[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());
}
/// Buffer search (Cmd+F) end-to-end: the case ("Aa") and regex (".*")
/// toggles change the match set, a broken regex flags an error instead of a
/// silent zero-match, and closing persists the query. Drives the real
/// `open_search` / `recompute_matches` / `close_search` path against a grid
/// seeded through the reader thread.
#[gpui::test]
fn buffer_search_honors_case_and_regex_toggles(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// Three lines differing only by case, so the case toggle is observable.
DaemonMsg::Output(b"Hello World\r\nhello world\r\nWORLD wide\r\n".to_vec())
.encode(&mut daemon)
.unwrap();
// Wait for the reader thread to parse the output into the grid.
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");
// Smart-case default: a lowercase query matches all three casings.
set_query(view, "world", window, cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 3);
assert!(!view.search_regex_error);
// Force case-sensitive: only the exact-lowercase line matches.
view.search_case_sensitive = true;
view.recompute_matches(cx);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 1);
view.search_case_sensitive = false;
// Literal mode: "wor.d" (a literal dot) matches nothing; regex
// mode turns "." into a wildcard so all three lines match.
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);
// A broken regex flags an error rather than a silent zero-match.
view.search_regex = true;
set_query(view, "(", window, cx);
assert!(view.search_regex_error);
assert_eq!(view.search.as_ref().unwrap().matches.len(), 0);
// The same query is a valid literal once regex mode is off.
view.search_regex = false;
view.recompute_matches(cx);
assert!(!view.search_regex_error);
// Closing remembers the query for the next open.
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();
}
/// CSI 14 t (text-area size in pixels) must be answered from the current
/// grid geometry — image TUIs (yazi, chafa) stall on a report that never
/// comes.
#[gpui::test]
fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// The window may have re-measured the grid by now — derive the
// expectation from whatever size the terminal actually has.
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());
}
/// The full ingress chain — daemon frame → reader thread → grid → event
/// pump → `handle_event(Wakeup)` — inside a real (headless) App. Guards
/// the pump against the "grid updated but the view never wakes" class of
/// bug, and the second frame proves the pump survives its own
/// redraw-scheduling step (a failed window refresh must degrade, never
/// tear the pump down).
#[gpui::test]
fn daemon_output_reaches_the_grid_through_the_event_pump(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// Bounded poll: the reader is a real OS thread, so give it wall-clock
// time, then let the foreground pump run between checks.
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");
// A second frame still lands: the pump outlived the first round-trip.
DaemonMsg::Output(b" again".to_vec())
.encode(&mut daemon)
.unwrap();
assert_eq!(wait_for(cx, "hello again"), "hello again");
}
/// Copy-on-select, end to end: real output through the pump, the same
/// start/update/end calls the mouse handlers make, then the clipboard.
/// Off (the default) the release must leave the clipboard alone; on, the
/// selected text lands at mouse-up with no ⌘C.
#[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();
// Bounded poll for the reader thread, as in the pump test above.
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));
}
// Drag across "hello" and release with the feature off: no copy.
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"
);
// Same gesture with the feature on: "hello" is on 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"));
// The mouse-up copy must NOT consume the selection: copy-on-select
// keeps the highlight, like every terminal with the feature.
let selected = window
.update(cx, |view, _, _| {
view.terminal.term.lock().selection.is_some()
})
.unwrap();
assert!(
selected,
"copy-on-select must keep the selection highlighted"
);
}
/// Ctrl+C means "copy the selection, else ^C (SIGINT)" — so the copy must
/// consume the selection, or a second Ctrl+C copies again forever and the
/// user can't interrupt the foreground command (#111). Cmd+C keeps the
/// selection (macOS convention), covered by the copy-on-select test above.
#[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();
// Bounded poll for the reader thread, as in the pump test above.
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| {
// Mouse-select "hello" (copy-on-select is off by default, so
// the selection survives mouse-up).
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");
// First Ctrl+C: copies and consumes the 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"
);
// Second Ctrl+C: no selection left, so the chord falls through
// to the raw ^C (SIGINT) path.
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"));
}
/// Pasting to the PTY consumes the selection like typing does, so the
/// reported select → copy → paste → Ctrl+C sequence ends in SIGINT (#111).
#[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();
// The pasted bytes still reach the PTY.
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
}
/// Same dual-purpose rule at the prompt: Ctrl+A selects the edited line,
/// Ctrl+C copies it — and must consume the editor selection so the next
/// Ctrl+C reaches the editor's ^C (abort line) instead of copying again
/// (#111, editor-selection variant).
#[gpui::test]
fn ctrl_c_copy_consumes_the_editor_selection_at_the_prompt(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
// Shell reports it is idle at its prompt: this is what flips
// `input_active()` true and puts the inline editor in charge.
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
// Poll until the prompt report has applied.
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();
// First Ctrl+C: copies the line and consumes the selection.
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"
);
// Second Ctrl+C: nothing selected anywhere, so the chord falls
// through to the editor's ^C (abort line) handling.
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"));
}
/// Reproduces the "orange caret jumps to the top-left corner after Claude
/// Code exits" bug at the state level, driving the two conditions that must
/// co-occur to trigger it:
///
/// 1. the shell is idle at its prompt (`DaemonMsg::Prompt` →
/// `input_active()`), so the inline editor is live and draws its own
/// caret via `render_input_bar`, which anchors at `cursor_cell()`; and
/// 2. the local grid's cursor *shape* is still `Hidden` — a full-screen
/// TUI hid the cursor with DECTCEM (`\e[?25l`) and handed back to the
/// prompt before a matching `\e[?25h` landed.
///
/// The cursor's real *position* is a valid cell (the prompt end), but the
/// stale-hidden shape used to make `cursor_cell()` return `None`, so
/// `render_input_bar`'s `unwrap_or((0, 0))` painted the caret at cell
/// `(0, 0)`. The assertions pin all three facts: the editor is active, the
/// shape genuinely is `Hidden` (the precondition that tripped the old
/// early-return), and `cursor_cell()` nonetheless reports the real cell.
#[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);
// Shell reports it is idle at its prompt: this is what flips
// `input_active()` true and puts the inline editor in charge.
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(0),
}
.encode(&mut daemon)
.unwrap();
// CUP to row 4 / col 11 (1-based), then hide the cursor as a TUI would
// on the way out — leaving the shape `Hidden` at a valid position.
DaemonMsg::Output(b"\x1b[4;11H\x1b[?25l".to_vec())
.encode(&mut daemon)
.unwrap();
// Poll until both the prompt report and the grid bytes have applied.
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"
);
}
/// A genuine child exit (`DaemonMsg::Exited`) must surface as a
/// `ChildExited` gpui event — the app's cue to close the pane/tab (the
/// "typing `exit` leaves a dead pane behind" bug). A daemon disconnect
/// marks the view exited through the same `AlacEvent::Exit` arm but must
/// emit nothing: auto-closing on a lost connection would silently discard
/// (and kill) a pane that may still be alive daemon-side.
#[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");
};
// The child really exits: the daemon says so.
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");
// The connection just drops.
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");
}
/// Regression for the "cursor vanishes after an ssh session dies mid-TUI"
/// bug. Over ssh, a remote full-screen TUI entered the alt screen and hid
/// the cursor (`\e[?1049h\e[?25l`). The network then drops: the restore
/// sequences (`\e[?25h`, `\e[?1049l`) never arrive, ssh exits, and the
/// *host* shell draws its prompt (reported via OSC 133 → `Prompt`).
///
/// Before the prompt-time scrub in the remote reader (see
/// `stale_mode_resets`), the grid stayed stranded on the alt screen with
/// a `Hidden` cursor shape, so *neither* cursor painted:
/// `element::build_grid` filters hidden grid cursors, and the inline
/// editor (which would ignore the stale-Hidden shape, see the test above)
/// never engaged because `input_active()` requires being off the alt
/// screen — a visible prompt with no cursor anywhere. The prompt report
/// must instead scrub the residue: off the alt screen, cursor shown,
/// editor live again.
#[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);
// Bytes that arrived over ssh before the drop: the remote TUI enters
// the alt screen and hides the cursor. The connection dies before any
// restore sequence is sent.
DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l".to_vec())
.encode(&mut daemon)
.unwrap();
// ssh exits; the host shell's integration reports a fresh prompt.
DaemonMsg::Prompt {
active: true,
at_prompt: true,
last_exit: Some(255), // ssh's exit code after a connection loss
}
.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"
);
// With the residue scrubbed, the inline editor engages and owns the
// caret again — the user sees a cursor at the prompt.
window
.update(cx, |view, _, _| {
assert!(
view.input_active(),
"off the alt screen and at the prompt, the editor is live"
);
})
.unwrap();
}
/// A generator that finishes while its menu is still open merges its results
/// in: candidates land, the set filters to the word as it now stands, and the
/// highlight settles on the closest match — the async half of #51's fix.
#[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| {
// A pure-generator slot: the menu opened with no sync candidates
// (word_start at the caret, empty open word). The user has since
// typed "ma", so the live word narrows what the results show.
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();
// "feature" filtered out by the live "ma"; closeness orders the
// rest; the top row is preselected.
assert_eq!(shown, vec!["main", "mainline"]);
assert_eq!(s.selected().unwrap().text, "main");
})
.unwrap();
}
/// A generator that finishes *after* its menu closed must not resurrect it:
/// closing bumps the generation, so the stale result is dropped.
#[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);
// The user dismisses the menu before the generator returns.
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"
);
// And a result for an old generation can't bleed into a *new*
// session that has since opened.
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();
}
}