Files
tty7/src/ui/app.rs
T
thomasandClaude Opus 4.8 4e97253b53 fix(cwd): never run local filesystem operations on a remote pane's cwd
A remote pane's OSC 7 reports a path in the remote's namespace. The
git-status probe, path completion, link resolution, the git-diff and
worktree shell-outs, session persistence and cwd inheritance all took it
as a local path.

Nothing gated this. `refresh_git_status`'s doc claimed remote panes have
no cwd, but that holds only *before* the remote shell's OSC 7 arrives —
after it, a native-SSH pane's remote path was fed to a local `git`, with
only `Path::exists()` incidentally saving it. That guard is weakest
exactly where it matters: on Windows `/home/me/proj` is not an invalid
path but a drive-relative one resolving to `C:\home\me\proj`, so a
machine that has such a directory would report an unrelated repo's branch
and diff as the pane's own. Correctness must not rest on that collision
never happening.

Add `TerminalView::local_cwd()` — the pane's cwd only when the pane is
not remote — and route every local filesystem/Command consumer through
it. Cwd inheritance for new tabs and splits is gated too: an inherited
cwd wins over every fallback in `initial_working_directory`, so a remote
path reached the spawn as a Win32 working directory.

Deliberately unchanged: `tab_cwd`, so "Copy Working Directory" still
copies a remote pane's remote path, which is what it is for.

Not fixed here: on a remote pane, path completion falls back to tty7's
own directory and link resolution stops matching relative paths. Both
degrade rather than mislead now; sourcing either from the remote is a
separate piece of work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:28:01 +08:00

5147 lines
218 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
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 window shell: a transparent unified title bar carrying the tab strip,
//! with the active terminal filling the rest. Owns all tabs (each its own PTY).
use gpui::{
App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div,
img, prelude::*, px,
};
use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
use gpui_component::input::{InputEvent, InputState};
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
use gpui_component::slider::{SliderEvent, SliderState};
use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _};
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::core::actions::*;
use crate::core::config::{
Config, CursorStyle as ConfigCursorStyle, NewTabPosition, ShellConfig, TabBarPosition,
};
use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab};
use crate::core::shells::DetectedShell;
use crate::core::ssh_config;
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
use crate::ui::pane::{CloseOutcome, Dir, Pane};
use crate::ui::presets::Fill;
use crate::ui::settings::{
Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action,
};
use crate::ui::theme::{apply_theme, set_menus, window_background};
/// One editable color of a user theme, targeted by the in-app color editor. Maps
/// a picker to the seed field (or ANSI slot) it writes back to the theme's file.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThemeEdit {
Background,
Foreground,
Accent,
Cursor,
Selection,
Ansi(usize),
}
/// Convert a picked `Hsla` to a `0xRRGGBB` value (alpha dropped) for storage in a
/// theme file.
fn hsla_to_u32(color: gpui::Hsla) -> u32 {
let rgba: gpui::Rgba = color.into();
let to = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u32;
(to(rgba.r) << 16) | (to(rgba.g) << 8) | to(rgba.b)
}
/// Global font-size bounds and step for the live zoom actions.
const FONT_SIZE_MIN: f32 = 6.0;
const FONT_SIZE_MAX: f32 = 48.0;
pub(crate) const FONT_SIZE_STEP: f32 = 1.0;
/// Line-height multiplier bounds and step for the Typography setting. 1.0 packs
/// rows flush against each other; 2.0 is very airy. 1.35 is the default.
const LINE_HEIGHT_MIN: f32 = 1.0;
const LINE_HEIGHT_MAX: f32 = 2.0;
pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05;
/// Cap on the recently-closed-tab stack, bounding memory and the JSON we'd
/// otherwise keep growing without limit.
const MAX_CLOSED_TABS: usize = 20;
/// How much one resize step nudges a split's ratio (see `resize_pane`). Matches
/// the divider's clamp band granularity in `pane.rs`.
const RESIZE_STEP: f32 = 0.05;
/// Quiet window after the last captured chord before a recorded shortcut is
/// committed (see `schedule_recording_commit`). Long enough to type a second
/// chord of a sequence (`ctrl-b x`), short enough that a single chord commits
/// promptly.
const RECORD_COMMIT_DELAY_MS: u64 = 650;
/// Height (px) of the unified title bar. Shared by `render` (the strip's height),
/// the settings overlay's nav-sidebar top zone, and the tab rail's top zone so
/// they all line up (and reach the very top of the window).
pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.;
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
/// tab is a real terminal tab.
pub struct Tab {
/// The tab's split-pane tree (one or more terminals).
pub pane: Pane,
/// User-set custom name (via "Rename Tab"). `None` → derive the label from
/// the focused terminal's title at render time.
pub name: Option<String>,
/// Entity id of the pane that last held focus in this tab. Recorded when we
/// leave the tab (see `remember_active_pane`) and restored on return, so
/// switching away and back keeps the active pane instead of jumping to the
/// first leaf. `None` for a tab never left, or after its focused pane closed
/// — both fall back to `first_leaf()`.
last_focused: Option<gpui::EntityId>,
/// `Some` while this tab has the working-tree diff overlay open (clicked
/// from a sidebar row's git line). Per-tab so switching away hides it and
/// switching back restores it; closing the tab drops it. Only the active
/// tab's overlay is rendered. See [`crate::ui::diff_overlay`].
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
/// The sidebar group this tab last *definitively* belonged to: the
/// repository home of its first pane's cwd — the main checkout's root, so
/// linked worktrees of one repo share a group (deliberately not the
/// focused pane's cwd — switching focus between splits must not relocate
/// the row), or `None` for outside any repo (the "Scratch" group). Sticky
/// on purpose: it only moves when
/// the git cache has a landed answer for the current cwd
/// ([`GitStatusCache::known_repo_for`](crate::terminal::git_status::GitStatusCache::known_repo_for)
/// returns `Some`), so a cd whose probe is still in flight — or a pane
/// with no cwd reported yet — keeps the row where it was instead of
/// flickering through the Scratch group and back. A `RefCell` because the
/// sidebar refreshes it during render, which only has `&Tab`.
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
}
impl Tab {
fn new(pane: Pane) -> Self {
Self {
pane,
name: None,
last_focused: None,
diff_overlay: None,
sidebar_group: std::cell::RefCell::new(None),
}
}
/// The pane to focus when this tab becomes active: the last-focused leaf if
/// it still exists, otherwise the first leaf.
fn focus_target(&self) -> Option<Entity<TerminalView>> {
match self.last_focused {
Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id),
None => self.pane.first_leaf(),
}
}
/// The title used to derive the tab label: the pane the tab is working in.
/// Only the *active* tab has a live focused pane, so for an inactive tab
/// (which holds no window focus) we fall back to the pane it last had
/// focused (`focus_target`) rather than always its first leaf — otherwise a
/// background tab's label would snap to its first pane. Without a `window`
/// (e.g. the command palette) the same `focus_target` is the best we have.
/// Empty when there's no terminal or no title yet.
pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String {
let leaf = match window {
Some(window) => self
.pane
.focused_leaf(window, cx)
.or_else(|| self.focus_target()),
None => self.focus_target(),
};
leaf.map(|l| l.read(cx).title.clone()).unwrap_or_default()
}
/// The git snapshot (branch + working-tree diff) of the tab's label-driving
/// terminal — the focused leaf with a `window`, else the first — for the
/// sidebar row's branch line (the branch and change count shown under the
/// title). Read through the shared per-repo cache, so tabs in one work
/// tree always agree. `None` when that leaf isn't inside a git work tree,
/// or before the repo's first probe lands.
pub(crate) fn git_status(
&self,
window: Option<&Window>,
cx: &App,
) -> Option<crate::terminal::git_status::GitStatus> {
let leaf = match window {
Some(window) => self.pane.focused_or_first(window, cx),
None => self.pane.first_leaf(),
}?;
leaf.read(cx).git_status(cx)
}
/// The coding agent running in this tab, or `None`. Any leaf counts (a
/// split with a shell on the left and Claude on the right is an agent
/// tab); the first agent leaf in tree order wins. Drives the tab avatar's
/// brand mark.
pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> {
self.pane
.leaves()
.into_iter()
.find_map(|l| l.read(cx).agent())
}
/// The tab's most urgent agent status across its leaves — waiting beats
/// working beats done beats idle — or `None` when no leaf runs an agent.
/// The green `Done` state always shows (a finished turn stays visible until
/// the next one); [`agent_unread_count`](Self::agent_unread_count) then
/// says how many of those finished turns are unread. Drives the avatar dot
/// and the sidebar counts.
pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> {
use crate::core::cli_agent::AgentStatus;
let urgency = |s: AgentStatus| match s {
AgentStatus::Waiting => 3,
AgentStatus::Working => 2,
AgentStatus::Done => 1,
AgentStatus::Idle => 0,
};
self.pane
.leaves()
.into_iter()
.filter(|l| l.read(cx).agent().is_some())
.map(|l| {
l.read(cx)
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle)
})
.max_by_key(|s| urgency(*s))
}
/// How many of the tab's panes hold an *unread* finished turn — a `Done`
/// the user hasn't looked at since. Drives the avatar dot's unread form:
/// the green dot swells into a count badge (a split tab can finish several
/// turns while you're away), and shrinks back to a plain dot once every
/// pane has been seen. Zero when the shown status isn't `Done` — a busier
/// pane (working/waiting) owns the corner until it settles.
pub(crate) fn agent_unread_count(&self, cx: &App) -> usize {
use crate::core::cli_agent::AgentStatus;
if self.agent_status(cx) != Some(AgentStatus::Done) {
return 0;
}
self.pane
.leaves()
.into_iter()
.filter(|l| {
let v = l.read(cx);
v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
&& v.agent_result_unread()
})
.count()
}
}
/// In-progress inline rename of a tab (double-click a tab label). Holds the
/// gpui-component text input plus the subscriptions that commit it on Enter/Blur.
pub(crate) struct Renaming {
/// Index of the tab being renamed, in `Tty7App::tabs`.
pub(crate) index: usize,
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
pub(crate) struct LoopbackForwardPanelState {
pub(crate) open_pane_id: Option<u64>,
/// The unified forwards list (Local/Remote/Dynamic, including auto localhost
/// forwards) for the open native-SSH pane (WS4).
pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>,
/// Add-forward form state (native-SSH panes only).
pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind,
pub(crate) mf_bind_host: Entity<InputState>,
pub(crate) mf_bind_port: Entity<InputState>,
pub(crate) mf_target_host: Entity<InputState>,
pub(crate) mf_target_port: Entity<InputState>,
pub(crate) mf_description: Entity<InputState>,
/// When editing an existing forward, the id being edited — the form shows
/// Save/Cancel and re-establishes the forward on save. `None` = adding.
pub(crate) mf_editing: Option<u64>,
}
pub struct Tty7App {
/// The open tabs; each owns a split-pane tree and an optional name.
pub(crate) tabs: Vec<Tab>,
pub(crate) active: usize,
/// Current global font size (px), applied to every pane in every tab.
pub(crate) font_size: f32,
/// Current global line-height multiplier, applied to every pane.
pub(crate) line_height: f32,
/// Currently-applied font family. Tracked (not just read from config on
/// demand) so the `Config`-global observer can tell a hot-reloaded family
/// change from the far more common no-op re-notify.
pub(crate) font_family: String,
/// Currently-applied distinct bold/italic faces (`None` = synthesized), also
/// tracked so the hot-reload observer can diff them like `font_family`.
pub(crate) font_family_bold: Option<String>,
pub(crate) font_family_italic: Option<String>,
/// Currently-applied OpenType features for terminal fonts. `None` means the
/// terminal-safe default (ligatures disabled).
pub(crate) font_features: Option<gpui::FontFeatures>,
/// Currently-applied terminal-emulator defaults. Tracked so hot-reload can
/// push only the alacritty-backed options that actually changed.
terminal_cursor_style: ConfigCursorStyle,
terminal_scrollback_limit: usize,
/// Keeps the `observe_global::<Config>` subscription alive for the app's
/// lifetime so external edits to `config.json` (swapped in by the watcher in
/// `main.rs`) live-apply font size / line height / family. Never read.
_config_watch: Subscription,
/// Keeps the keystroke interceptor alive: any real keypress cancels the
/// held-⌘/Ctrl tab badges (and any pending reveal), so a chord like ⌘C
/// never shows them — only a bare hold does. An *interceptor* (fires
/// pre-dispatch) rather than an observer because the terminal consumes
/// most keys with `stop_propagation`, which suppresses observers. Never read.
_keystroke_watch: Subscription,
/// Keeps the window-activation observer alive: any active-status flip also
/// cancels the badges. Deactivating mid-hold (⌘-Tab, Spotlight, a click
/// into another app) sends the modifier *release* to whatever app is key
/// by then — this window never gets that `ModifiersChanged`, so without
/// this the badges stuck on until some later keypress. Never read.
_activation_watch: Subscription,
/// Keeps the `observe_global::<GitStatusCache>` subscription alive: a git
/// probe landing (from *any* pane) repaints the sidebar, so every row in
/// the same repo shows the just-refreshed branch/diff line, not a stale
/// per-row copy. Never read.
_git_status_watch: Subscription,
/// Keeps the window-appearance observer alive: while
/// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves
/// the theme slot and repaints. Never read.
_appearance_watch: Subscription,
/// `Some` while the command palette overlay is open; `None` when closed.
/// The view owns its search input, filtered list and keyboard handling and
/// emits a `PaletteEvent`; we build the catalog and run the chosen command.
palette: Option<Entity<PaletteView>>,
/// Keeps the open palette's event subscription alive; dropped on close.
palette_sub: Option<Subscription>,
/// Stack of recently closed tabs (most recent on top) for Cmd+Shift+T.
/// Stored serialized so each entry carries the panes' cwd + name at close.
/// `pub(crate)` so the home page can surface the top entry as its
/// "reopen what you just closed" hint.
pub(crate) closed: Vec<SessionTab>,
/// `Some` while a tab label is being renamed inline; `None` otherwise.
pub(crate) renaming: Option<Renaming>,
/// `Some` while the "New Worktree Tab" sheet is open (see
/// `ui::worktree_prompt`); `None` otherwise.
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
/// When `Some`, the active tab renders only this one leaf full-window
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
maximized: Option<Entity<TerminalView>>,
/// Whether the tab chips currently show their ⌘1…⌘9 switch badges
/// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`).
pub(crate) mod_hint_badges: bool,
/// Generation counter for the delayed badge reveal: bumped on every
/// modifier transition and keypress so a stale timer can't fire.
pub(crate) mod_hint_gen: u64,
/// Generation counter for the keybinding-capture commit timer: bumped on
/// every captured chord, cancel, and start, so a stale pause-to-commit
/// timer can't fire after the sequence changed or capture ended.
record_gen: u64,
/// Focus target for the home page (the zero-tab state; see `ui::home`).
/// Keeping something focused keeps keystrokes flowing through the window's
/// dispatch path, so ⌘T & friends still reach the root action handlers.
pub(crate) home_focus: gpui::FocusHandle,
/// Shells found on this machine (`core::shells::detect_shells`), listed in
/// the "+" dropdown. Probed once at startup off the UI thread — empty until
/// that lands, when the dropdown offers just the default entry.
pub(crate) detected_shells: Vec<DetectedShell>,
/// Pane-contextual SSH loopback forward UI state. The controls render only
/// over the active SSH pane, but the input/editing state is app-owned so it
/// is not tied to the Settings tab.
pub(crate) loopback_panel: LoopbackForwardPanelState,
/// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane.
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
/// Vertical tab sidebar width (px), held in a shared `Cell` so the resize
/// drag's window-level mouse listener can mutate it without the entity handle
/// (mirrors the split divider's `ratio`). Seeded from `Config::sidebar_width`
/// and persisted back when a drag ends.
pub(crate) sidebar_width: Rc<Cell<f32>>,
/// Whether the sidebar's resize handle is currently held.
pub(crate) sidebar_dragging: Rc<Cell<bool>>,
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
/// row into view.
pub(crate) sidebar_scroll: gpui::ScrollHandle,
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
pub(crate) sidebar_search: Entity<InputState>,
/// Re-renders the sidebar on each search keystroke so results narrow live.
_sidebar_search_sub: Subscription,
/// `Some` while the settings page is open. Settings is a full-window overlay
/// (not a tab), so it covers the tab rail / title bar and never clutters the
/// tab list. Holds all the settings widget state + its subscriptions.
settings: Option<SettingsState>,
/// In-pane native-SSH auth / host-key sheet state (WS3). Holds the active
/// prompt (keyed to the pane that raised it), its input widgets, and
/// dismissable banners. Empty when no prompt is pending.
pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState,
/// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close
/// action awaiting confirmation, or `None` when no prompt is up.
pub(crate) ssh_close_confirm: Option<SshCloseKind>,
/// Latest window geometry (the restore bounds while fullscreen), kept
/// current by a bounds observer so the quit hook can persist it to
/// `window.json` — at quit time no `&Window` is in reach to ask directly.
window_bounds: Bounds<Pixels>,
}
/// Which close action a live-SSH close-confirmation is gating (PRD FR-E3).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshCloseKind {
/// Close the whole tab at this index.
Tab(usize),
/// Close the focused pane.
Pane,
}
impl Tty7App {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Restore the previous session (tab/split layout + each pane's cwd),
// unless the user turned restore off — then start fresh. `None` takes the
// first-run path in `with_session`, spawning a single default terminal.
let session = if cx.global::<Config>().restore_session {
Session::load()
} else {
None
};
let app = Self::with_session(session, window, cx);
// If startup reused a daemon that speaks a different wire protocol
// (an app upgrade while the old service kept running), the sessions
// just restored above are living on that old dialect. Surface the
// keep-or-restart choice now that there's a window to ask in.
Self::prompt_daemon_version_mismatch(window, cx);
app
}
/// Ask what to do about a protocol-mismatched daemon that
/// `spawn::ensure_running` deliberately left running (rather than silently
/// killing every persisted session at startup): keep using it — sessions
/// survive, features whose wire shape changed may misbehave — or restart
/// the service clean via the shared
/// [`restart_daemon_confirmed`](Self::restart_daemon_confirmed) path
/// (tabs reopen with fresh shells). Keeping is the default: dismissing
/// the prompt changes nothing.
fn prompt_daemon_version_mismatch(window: &mut Window, cx: &mut Context<Self>) {
let Some(mismatch) = crate::daemon::spawn::take_mismatched_daemon() else {
return;
};
let ours = crate::daemon::protocol::PROTOCOL_VERSION;
let detail = match mismatch.version {
Some(v) => format!(
"The daemon holding your sessions is from another build \
(v{}, protocol {} — this app speaks {}). You can keep using it and \
your sessions stay, but features whose wire format changed may \
misbehave until it's restarted. Restarting starts a clean daemon: \
tabs reopen with fresh shells and anything running in them is \
terminated.",
v.build, v.protocol, ours
),
None => "The daemon holding your sessions is from an older \
version of the app. You can keep using it and your sessions stay, \
but newer features may misbehave until it's restarted. Restarting \
starts a clean daemon: tabs reopen with fresh shells and anything \
running in them is terminated."
.to_string(),
};
let answer = window.prompt(
PromptLevel::Warning,
"Daemon Is From Another Version",
Some(&detail),
&["Keep Sessions", "Restart Daemon"],
cx,
);
cx.spawn(async move |this, cx| {
// Index 1 == "Restart Daemon"; "Keep Sessions" or a dismissed
// prompt leave the old daemon (and every session) untouched.
if !matches!(answer.await, Ok(1)) {
return;
}
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
})
.detach();
}
/// The whole constructor behind `new`, with the saved session injected
/// instead of read from disk. The headless tests build the app through
/// this seam (a zero-tab session → the home page, no terminal spawned)
/// so every subscription and window hook runs exactly as in production
/// without touching `~/.config` or a daemon.
pub(crate) fn with_session(
session: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Font size from config (borrow ends before the mutable theme apply).
let (
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_family_bold.clone(),
cfg.font_family_italic.clone(),
cfg.font_features.clone(),
cfg.cursor_style,
cfg.scrollback_limit,
)
};
let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx);
// Managed-forward add-form inputs (native-SSH panes).
let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1"));
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1"));
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description"));
let sidebar_width = cx.global::<Config>().sidebar_width;
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
// `Config` global on every `config.json` change, which fires this. The
// window-aware variant so the reload can re-run `apply_theme` with the
// window (blur flip, traffic-light pinning) and re-sync the Appearance
// opacity slider — the watcher task itself has no window handle.
let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| {
this.reload_from_config(window, cx)
});
// Repaint when any pane's git probe lands in the shared cache — the
// sidebar's branch/diff lines read from it, and the probing pane's own
// notify wouldn't re-render rows belonging to *other* panes. The open
// diff overlay rides the same signal: if the landed numbers disagree
// with what it shows, it re-probes the full diff.
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
let git_status_watch =
cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| {
this.maybe_refresh_diff_overlay(cx);
cx.notify();
});
// Any real keypress means "chord, not a bare hold": cancel the held-⌘
// tab badges and whatever reveal is pending (see `ui::hints`).
let this = cx.weak_entity();
let keystroke_watch = cx.intercept_keystrokes(move |_ev, _window, cx| {
let _ = this.update(cx, |this, cx| this.dismiss_mod_hint(cx));
});
// Losing key status mid-hold (⌘-Tab, Spotlight, a click into another
// app) means the modifier release is delivered elsewhere and never
// reaches this window — the activation flip is the only signal left,
// so treat it like a release. Dismissing on *both* flips also keeps a
// reveal scheduled just before the switch from popping the badges up
// in a window the user already left.
let activation_watch = cx.observe_window_activation(window, |this, _window, cx| {
this.dismiss_mod_hint(cx);
// The panes' link-modifier tracking loses the release the same
// way, and a stale "⌘ held" is worse than missing badges: a
// plain unmodified click would open links. Treat the flip as a
// release; holding ⌘ again re-arms it via `on_modifiers_changed`.
this.set_link_modifier(false, cx);
});
// Follow OS light/dark flips live: while "sync with system" is on, an
// appearance change re-resolves the theme slot and repaints. While it's
// off the appearance only ever changes because `apply_theme` pinned it
// to the theme — skip, or the pin would re-trigger a redundant apply.
let this = cx.weak_entity();
let appearance_watch = window.observe_window_appearance(move |window, cx| {
if !cx.global::<Config>().theme_follow_system {
return;
}
apply_theme(Some(window), cx);
let _ = this.update(cx, |this, cx| {
// The editor targets the on-screen theme, and with no global
// override the opacity slider follows it — keep both in step.
this.rebuild_theme_editor(window, cx);
this.sync_window_opacity_slider(window, cx);
cx.notify();
});
});
// Paint the configured color theme (defaults to a light one) and build
// the menu bar.
apply_theme(Some(window), cx);
set_menus(cx);
// A session with zero tabs is a real state — the user quit from
// the home page — and restores back to it; only a *missing/unreadable*
// session (first run) falls back to spawning a default terminal.
let (tabs, active) = match session {
// First run (no session file): the very first terminal has no
// predecessor to inherit from, so start in the app's current
// directory (None → default behavior).
None => {
let first = new_terminal(font_size, None, None, None, window, cx);
(vec![Tab::new(Pane::leaf(first))], 0)
}
// A saved session (with tabs, or an empty home-page state): rebuild it
// the same way a daemon restart does.
some => tabs_from_session(some, font_size, window, cx),
};
// Sidebar tab filter. Each keystroke re-renders the (cheap) row list so
// results narrow as you type — the same live-filter wiring the theme
// picker uses.
let sidebar_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search tabs…"));
let sidebar_search_sub =
cx.subscribe_in(&sidebar_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let app = Self {
tabs,
active,
font_size,
line_height,
font_family,
font_family_bold,
font_family_italic,
font_features,
terminal_cursor_style,
terminal_scrollback_limit,
_config_watch: config_watch,
_keystroke_watch: keystroke_watch,
_activation_watch: activation_watch,
_git_status_watch: git_status_watch,
_appearance_watch: appearance_watch,
palette: None,
palette_sub: None,
closed: Vec::new(),
renaming: None,
worktree_prompt: None,
maximized: None,
mod_hint_badges: false,
mod_hint_gen: 0,
record_gen: 0,
home_focus: cx.focus_handle(),
detected_shells: Vec::new(),
loopback_panel: LoopbackForwardPanelState {
open_pane_id: None,
managed: Vec::new(),
mf_kind: crate::daemon::protocol::SshForwardKind::Local,
mf_bind_host,
mf_bind_port,
mf_target_host,
mf_target_port,
mf_description,
mf_editing: None,
},
sftp_panel,
sidebar_width: Rc::new(Cell::new(sidebar_width)),
sidebar_dragging: Rc::new(Cell::new(false)),
sidebar_scroll: gpui::ScrollHandle::new(),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
settings: None,
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
ssh_close_confirm: None,
window_bounds: window.window_bounds().get_bounds(),
};
// Bring the system tray up (icon + agent menu + poll loop). Skipped in
// tests: the headless harness has no native status bar to register
// with, and the poll task would just spin against the mocked clock.
if !cfg!(test) {
crate::ui::tray::init(cx);
}
// Discover this machine's shells for the "+" dropdown off the UI thread
// (the WSL probe on Windows spawns a process, and /etc/shells hits the
// filesystem). Until it lands the dropdown offers just the default entry.
cx.spawn(async move |this, cx| {
let shells = cx
.background_spawn(async { crate::core::shells::detect_shells() })
.await;
// `notify` so the strip re-renders and the dropdown closure
// captures the freshly landed list (nothing else is guaranteed to
// redraw an idle window).
let _ = this.update(cx, |app, cx| {
app.detected_shells = shells;
cx.notify();
});
})
.detach();
// Persist the session one last time as the app quits. This captures the
// latest state — including a plain `cd` that changed a pane's cwd but
// triggered no structural change — so the next launch restores where the
// user actually left off. The callback gets the live `Tty7App`, reads
// every pane's current cwd, and writes the file synchronously; the empty
// future just satisfies the hook's async signature. The subscription is
// detached to live for the app's lifetime (its weak handle keeps it safe
// after teardown).
cx.on_app_quit(|app, cx| {
app.save_session(cx);
// Also persist the window's final geometry so the next launch can
// reopen there (`remember_window_size`). Written unconditionally —
// startup gates on the config — so toggling the setting back on
// restores the most recent quit, not some stale pre-toggle state.
crate::core::window_state::WindowState::from_bounds(app.window_bounds).save();
async move {}
})
.detach();
// Keep `window_bounds` tracking the live window: moves and resizes both
// fire this observer, and `window_bounds()` reports the *restore* bounds
// while fullscreen, so a fullscreen quit doesn't record a screen-sized
// window for the next normal launch.
cx.observe_window_bounds(window, |this, window, _cx| {
this.window_bounds = window.window_bounds().get_bounds();
})
.detach();
// Confirm before the red traffic light closes the window. Closing quits
// the app, but the panes are *detached, not killed* — they keep running in
// the daemon and re-attach on the next launch — so the prompt reassures
// rather than warns. We veto the immediate close (return `false`), show the
// prompt, and quit only if the user picks "Close". A one-shot flag lets
// that post-confirm quit through should we be asked again, instead of
// looping the prompt.
let close_confirmed = std::rc::Rc::new(std::cell::Cell::new(false));
let weak_app = cx.weak_entity();
window.on_window_should_close(cx, move |window, cx| {
if close_confirmed.get() {
return true;
}
// From the home page (zero tabs) there are no running sessions to
// reassure about — prompting would be pure friction. Close directly.
if weak_app
.upgrade()
.is_some_and(|app| app.read(cx).tabs.is_empty())
{
return true;
}
let answer = window.prompt(
PromptLevel::Info,
"Close Window?",
Some(
"Your sessions keep running in the background and will be \
restored the next time you open tty7.",
),
&["Cancel", "Close"],
cx,
);
let close_confirmed = close_confirmed.clone();
cx.spawn(async move |cx| {
// Index 1 == "Close"; index 0 (Cancel) and a dismissed prompt
// both leave the window open.
if let Ok(1) = answer.await {
close_confirmed.set(true);
cx.update(|cx| cx.quit());
}
})
.detach();
false
});
app.focus_active(window, cx);
app
}
/// Snapshot the current tabs/active index into a `Session` and persist it.
/// Called after every structural change; the write is a small synchronous
/// JSON dump and any error is swallowed inside `Session::save`.
fn save_session(&self, cx: &App) {
let tabs: Vec<SessionTab> = self
.tabs
.iter()
.map(|tab| tab_to_session(tab, cx))
.collect();
// Zero tabs is a real state (the home page) and is persisted as such, so
// the next launch comes back to it instead of a fresh shell.
if tabs.is_empty() {
Session::default().save();
return;
}
let active = self.active.min(tabs.len() - 1);
let session = Session { active, tabs };
session.save();
}
/// Reopen the most recently closed tab (Cmd+Shift+T). Rebuilds its pane
/// tree (restoring each terminal's saved cwd), inserts it after the active
/// tab, and focuses it. No-op when the stack is empty.
fn reopen_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(st) = self.closed.pop() else {
return;
};
let alive = alive_panes();
let pane = session_to_pane(&st.pane, &alive, self.font_size, window, cx);
// Leaving the current tab for the reopened one; snapshot its focused
// pane so switching back restores it (same as `activate`).
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(
insert_at,
Tab {
pane,
name: st.name,
last_focused: None,
diff_overlay: None,
// Keep the group it had when closed — the row reappears where
// it lived instead of flashing through Scratch.
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
},
);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
// ── System tray (`ui::tray`) ────────────────────────────────────────────
/// Snapshot every agent pane for the tray menu: brand name, status, and a
/// "where" line (cwd directory name + git branch). Most urgent first, so
/// the pane that needs the user tops the menu. Called by the tray's poll
/// loop once a second; the walk is a handful of entity reads.
pub(crate) fn tray_snapshot(&self, cx: &App) -> crate::ui::tray::TraySnapshot {
use crate::core::cli_agent::AgentStatus;
let urgency = |s: AgentStatus| match s {
AgentStatus::Waiting => 3,
AgentStatus::Working => 2,
AgentStatus::Done => 1,
AgentStatus::Idle => 0,
};
let mut agents = Vec::new();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let view = leaf.read(cx);
let Some(agent) = view.agent() else { continue };
let status = view
.agent_session()
.map(|s| s.status)
.unwrap_or(AgentStatus::Idle);
let dir = view
.cwd()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()));
let branch = view.git_status(cx).map(|g| g.branch);
let detail = match (dir, branch) {
(Some(dir), Some(branch)) => format!("{dir} @ {branch}"),
(Some(dir), None) => dir,
// No cwd yet (pane still spawning) — the agent name alone
// still identifies the row.
(None, _) => String::new(),
};
agents.push(crate::ui::tray::AgentRow {
leaf_id: leaf.entity_id().as_u64(),
agent,
status,
detail,
});
}
}
agents.sort_by_key(|a| std::cmp::Reverse(urgency(a.status)));
crate::ui::tray::TraySnapshot {
agents,
notify_mode: cx.global::<Config>().notify_on_command_finish,
}
}
/// Apply a tray menu click. Runs on the foreground executor with the
/// window in hand (see `tray::init`'s action pump).
pub(crate) fn handle_tray_action(
&mut self,
action: crate::ui::tray::TrayAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
use crate::ui::tray::TrayAction;
// Tray clicks arrive while another app is frontmost — that's the
// tray's whole premise. `activate_window` alone only orders our
// window front within the app (macOS: `makeKeyAndOrderFront:`); the
// *application* must also be activated or the reveal — and any
// window-modal prompt we show next — stays buried behind the app the
// user clicked from.
fn surface_window(window: &mut Window, cx: &mut App) {
cx.activate(true);
window.activate_window();
}
match action {
TrayAction::ShowWindow => surface_window(window, cx),
TrayAction::RevealPane { leaf_id } => {
// Resolve the leaf against the *live* tree — the menu the user
// clicked may predate a tab close; a vanished pane is a no-op
// (the window still comes forward).
let tab_ix = self.tabs.iter().position(|t| {
t.pane
.leaves()
.iter()
.any(|l| l.entity_id().as_u64() == leaf_id)
});
if let Some(ix) = tab_ix {
self.activate(ix, window, cx);
// The reveal must actually show the pane: a sibling leaf
// maximized in this tab would otherwise keep the target
// off-screen while we hand it keyboard focus. The target
// itself staying maximized is fine — it's already the
// visible one.
if self
.maximized
.as_ref()
.is_some_and(|m| m.entity_id().as_u64() != leaf_id)
{
self.maximized = None;
}
if let Some(leaf) = self.tabs[ix]
.pane
.leaves()
.into_iter()
.find(|l| l.entity_id().as_u64() == leaf_id)
{
self.tabs[ix].last_focused = Some(leaf.entity_id());
self.focus_leaf(&leaf, window, cx);
}
cx.notify();
}
surface_window(window, cx);
}
TrayAction::SetNotifyMode(mode) => self.set_notify_mode(mode, cx),
TrayAction::OpenSettings => {
surface_window(window, cx);
if self.settings.is_none() {
self.toggle_settings(window, cx);
}
}
TrayAction::CheckForUpdates => {
surface_window(window, cx);
// Forced: a manual "check now" should work even when the
// startup check is disabled. The result lands in the About
// panel we open next (via the `UpdateStatus` global).
crate::core::update::spawn_check_forced(cx);
self.open_settings_section(SettingsSection::About, window, cx);
}
// Same as ⌘Q: sessions keep running in the daemon.
TrayAction::Quit => cx.quit(),
TrayAction::QuitStopSessions => self.quit_stop_sessions(window, cx),
}
}
/// Tray "Quit and Stop Daemon": confirm, shut the daemon down (which
/// hangs up every shell — the whole point of picking this over plain
/// quit), then quit. The stop runs off the UI thread; like
/// `--stop-daemon` it can take a beat while children get their grace
/// period.
fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// The prompt is window-modal and the click came from the tray with
// another app frontmost — activate the app AND the window, or the
// user never sees the question.
cx.activate(true);
window.activate_window();
let answer = window.prompt(
PromptLevel::Warning,
"Quit and Stop Daemon?",
Some(
"This quits tty7 and stops the background daemon — anything \
still running in your sessions is terminated. Your tabs and \
layout are kept and reopen with fresh shells next launch. \
(Plain Quit keeps sessions running.)",
),
&["Cancel", "Quit and Stop"],
cx,
);
cx.spawn(async move |_this, cx| {
// Index 1 == "Quit and Stop"; Cancel or a dismissed prompt do nothing.
if !matches!(answer.await, Ok(1)) {
return;
}
cx.background_spawn(async { crate::daemon::spawn::stop() })
.await;
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
/// Restart the persistent background daemon: shut the running one down (which
/// stops every live shell) and bring a fresh one up, then rebuild the tabs
/// from the just-saved session so the layout returns with fresh shells.
///
/// A general escape hatch for the otherwise invisible, always-on daemon:
/// picking up a macOS permission granted after it started (Full Disk Access
/// and the like only reach it on a fresh process), recovering if it wedges, or
/// just starting from a clean slate — none of which quitting/reopening the GUI
/// achieves, since that leaves the detached daemon untouched. Guarded by a
/// confirmation because it ends running sessions. The shutdown + respawn runs
/// off the UI thread (the daemon hangs up each child with a short grace, so it
/// can take a beat); the tab rebuild hops back to the main thread, where it has
/// the `Window`.
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let answer = window.prompt(
PromptLevel::Warning,
"Restart Daemon?",
Some(
"This stops every running terminal session — anything still \
running in them will be terminated. Your tabs and layout are kept \
and reopened with fresh shells.",
),
&["Cancel", "Restart"],
cx,
);
cx.spawn(async move |this, cx| {
// Index 1 == "Restart"; Cancel or a dismissed prompt leave everything
// running untouched.
if !matches!(answer.await, Ok(1)) {
return;
}
let _ = this.update_in(cx, |this, _window, cx| this.restart_daemon_confirmed(cx));
})
.detach();
}
/// The restart itself, past any confirmation — shared by
/// [`restart_daemon`](Self::restart_daemon)'s prompt and the startup
/// version-mismatch prompt
/// ([`prompt_daemon_version_mismatch`](Self::prompt_daemon_version_mismatch)).
fn restart_daemon_confirmed(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
// Persist the current layout + cwds, then tear the live terminals down
// *before* the daemon dies: dropping each `RemoteTerminal` detaches its
// socket, so no reader thread is mid-read when the daemon exits. The
// window briefly shows the empty home page while the daemon restarts.
if this
.update_in(cx, |this, _window, cx| {
this.save_session(cx);
this.maximized = None;
this.tabs.clear();
this.active = 0;
cx.notify();
})
.is_err()
{
return;
}
// Shut the old daemon down and spawn a fresh one off the UI thread.
let restarted = cx
.background_spawn(async move { crate::daemon::spawn::restart() })
.await;
// Rebuild from the saved session. The fresh daemon has no live panes,
// so every leaf spawns a new shell in its saved cwd and the tab/split
// layout returns exactly as it was.
let _ = this.update_in(cx, |this, window, cx| {
match &restarted {
Ok(()) => {
let font_size = this.font_size;
let (tabs, active) =
tabs_from_session(Session::load(), font_size, window, cx);
this.tabs = tabs;
this.active = active;
}
// The fresh daemon never came up; rebuilding would panic in
// `new_terminal`'s connect `.expect`. Stay on the home page and
// leave a breadcrumb rather than crash — the user can retry.
Err(e) => {
log::error!("restart background service failed, staying on home page: {e}");
}
}
this.focus_active(window, cx);
cx.notify();
});
})
.detach();
}
/// Apply `size` (clamped) as the new global font size across every pane.
/// The element re-measures cell geometry next frame, so the grid reflows
/// automatically once each view is notified.
fn set_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
let size = size.clamp(FONT_SIZE_MIN, FONT_SIZE_MAX);
self.font_size = size;
let px_size = px(size);
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.font_size = px_size;
cx.notify();
});
}
}
// Persist so the zoom level survives a restart.
let cfg = cx.global_mut::<Config>();
cfg.font_size = size;
cfg.save();
cx.notify();
}
pub(crate) fn change_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_font_size(self.font_size + delta, cx);
}
/// Reset the global font size back to the built-in default. We use the
/// compiled-in default rather than `config.font_size`, because the latter now
/// tracks the live zoom level (persisted on every change), so it no longer
/// serves as a stable reset target.
pub(crate) fn reset_font_size(&mut self, cx: &mut Context<Self>) {
self.set_font_size(Config::default().font_size, cx);
}
/// Apply `mul` (clamped) as the new global line-height multiplier across every
/// pane. Like `set_font_size`, the element re-derives row height next frame, so
/// the grid reflows once each view is notified.
fn set_line_height(&mut self, mul: f32, cx: &mut Context<Self>) {
let mul = mul.clamp(LINE_HEIGHT_MIN, LINE_HEIGHT_MAX);
self.line_height = mul;
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.line_height_mul = mul;
cx.notify();
});
}
}
// Persist so the spacing survives a restart.
let cfg = cx.global_mut::<Config>();
cfg.line_height = mul;
cfg.save();
cx.notify();
}
pub(crate) fn change_line_height(&mut self, delta: f32, cx: &mut Context<Self>) {
self.set_line_height(self.line_height + delta, cx);
}
/// Reset the line-height multiplier back to the built-in default (see the note
/// on `reset_font_size`: config now tracks the live value, not a reset target).
pub(crate) fn reset_line_height(&mut self, cx: &mut Context<Self>) {
self.set_line_height(Config::default().line_height, cx);
}
/// Switch the active color theme by id, repaint, and persist the choice so
/// it survives a restart. The theme carries its own dark/light brightness.
/// While the system is being followed, the choice lands in the slot for the
/// *current* OS appearance (the theme visibly on screen changes either way).
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
let dark_now = crate::ui::theme::system_dark(cx);
let cfg = cx.global_mut::<Config>();
if !cfg.theme_follow_system {
cfg.theme_preset = id.to_string();
} else if dark_now {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
self.after_theme_change(window, cx);
}
/// Set the theme for one follow-system slot explicitly (the Light / Dark
/// cards in Settings). Only visibly changes anything when that slot is the
/// one currently on screen; either way the choice is persisted.
pub(crate) fn set_slot_preset(
&mut self,
dark_slot: bool,
id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cfg = cx.global_mut::<Config>();
if dark_slot {
cfg.theme_preset_dark = id.to_string();
} else {
cfg.theme_preset_light = id.to_string();
}
self.after_theme_change(window, cx);
}
/// Turn "sync with system appearance" on/off (the Appearance switch).
/// Turning it off never visibly changes the theme: whatever is on screen
/// is adopted as the manual choice. Turning it on seeds the slot matching
/// the manual theme's own brightness with that theme — so the look only
/// changes when the OS is currently in the *other* mode, where switching
/// to that mode's slot is exactly what the feature promises.
pub(crate) fn set_theme_follow_system(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if on {
let manual = cx.global::<Config>().theme_preset.clone();
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = true;
if manual_dark {
cfg.theme_preset_dark = manual;
} else {
cfg.theme_preset_light = manual;
}
} else {
// Resolve while following is still on (the pin is released, so
// this reads the real OS appearance).
let effective = crate::ui::theme::effective_preset_id(cx);
let cfg = cx.global_mut::<Config>();
cfg.theme_follow_system = false;
cfg.theme_preset = effective;
}
self.after_theme_change(window, cx);
// Re-aim an open picker panel at a slot that exists in the new mode —
// after the apply above, so `system_dark` reads the unpinned OS value.
let slot = if on {
if crate::ui::theme::system_dark(cx) {
crate::ui::settings::ThemeSlot::Dark
} else {
crate::ui::settings::ThemeSlot::Light
}
} else {
crate::ui::settings::ThemeSlot::Manual
};
if let Some(s) = self.active_settings_mut() {
s.theme_panel_slot = slot;
}
}
/// The shared tail of every theme-selection change: repaint, persist, and
/// keep the dependent Settings widgets in step.
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
apply_theme(Some(window), cx);
set_menus(cx);
cx.global::<Config>().save();
// The editor targets the active theme, so its pickers must track a switch.
self.rebuild_theme_editor(window, cx);
// With no global override, the effective opacity follows the theme — keep
// the Appearance slider's thumb on it.
self.sync_window_opacity_slider(window, cx);
cx.notify();
}
/// Show/hide the theme picker panel beside the Appearance page. Clicking
/// the card whose slot the open panel already targets closes it; clicking
/// another card re-aims the open panel at that slot.
pub(crate) fn toggle_theme_panel(
&mut self,
slot: crate::ui::settings::ThemeSlot,
cx: &mut Context<Self>,
) {
if let Some(s) = self.active_settings_mut() {
if s.theme_panel_open && s.theme_panel_slot == slot {
s.theme_panel_open = false;
} else {
s.theme_panel_open = true;
s.theme_panel_slot = slot;
}
cx.notify();
}
}
/// Close the theme picker panel (its `×`).
pub(crate) fn close_theme_panel(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.theme_panel_open = false;
cx.notify();
}
}
/// Open the user themes folder (`~/.config/tty7/themes`) in the system file
/// browser, creating it first so there's always somewhere to drop a theme.
pub(crate) fn open_themes_folder(&self, cx: &mut Context<Self>) {
if let Some(dir) = crate::ui::presets::themes_dir() {
let _ = std::fs::create_dir_all(&dir);
cx.open_with_system(&dir);
}
}
/// Duplicate the active theme into an editable YAML file, switch to the copy,
/// and open the color editor on it. This is the entry point for customizing a
/// read-only built-in (or an imported iTerm scheme).
pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
match crate::ui::presets::fork_to_file(&theme) {
Ok(new_id) => {
crate::ui::presets::load_registry(cx);
// Switches to the copy (applies + persists + rebuilds the editor).
self.set_preset(&new_id, window, cx);
}
Err(e) => log::warn!("failed to duplicate theme: {e}"),
}
}
/// Apply one edit to the active (editable) theme: mutate it, write the
/// theme's file, reload the registry, and repaint live. The shared tail of
/// every in-app theme edit (color pickers, opacity slider, blur switch).
fn mutate_active_theme(
&mut self,
mutate: impl FnOnce(&mut crate::ui::presets::Theme),
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = crate::ui::theme::effective_preset_id(cx);
let mut theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
return;
}
mutate(&mut theme);
if let Err(e) = crate::ui::presets::write_theme_file(&theme) {
log::warn!("failed to write theme file: {e}");
return;
}
crate::ui::presets::load_registry(cx);
apply_theme(Some(window), cx);
cx.notify();
}
/// Apply one color edit to the active (editable) theme.
pub(crate) fn edit_active_theme(
&mut self,
edit: ThemeEdit,
value: gpui::Hsla,
window: &mut Window,
cx: &mut Context<Self>,
) {
let c = hsla_to_u32(value);
self.mutate_active_theme(
|theme| match edit {
ThemeEdit::Background => theme.background = Fill::Solid(c),
ThemeEdit::Foreground => theme.foreground = c,
ThemeEdit::Accent => theme.accent = c,
ThemeEdit::Cursor => theme.caret = Some(c),
ThemeEdit::Selection => theme.selection = Some(c),
ThemeEdit::Ansi(i) => theme.ansi16[i] = ((c >> 16) as u8, (c >> 8) as u8, c as u8),
},
window,
cx,
);
}
/// The window opacity currently in effect: the global config override when
/// set, else the active theme's own value, else fully opaque.
pub(crate) fn effective_window_opacity(cx: &App) -> f32 {
let config = cx.global::<Config>();
let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
config.window_opacity.or(theme.opacity).unwrap_or(1.0)
}
/// Set the global window-opacity override from the Appearance slider. Applies
/// to every theme (persisted in the config, not the theme file).
pub(crate) fn set_window_opacity(
&mut self,
v: f32,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().window_opacity = Some(v.clamp(0.2, 1.0));
apply_theme(Some(window), cx);
cx.global::<Config>().save();
cx.notify();
}
/// Set the global window-blur override from the Appearance switch.
pub(crate) fn set_window_blur(
&mut self,
on: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.global_mut::<Config>().window_blur = Some(on);
apply_theme(Some(window), cx);
cx.global::<Config>().save();
cx.notify();
}
/// Clear both window overrides so opacity/blur follow the active theme again
/// (the Appearance section's "Follow theme" action).
pub(crate) fn reset_window_overrides(&mut self, window: &mut Window, cx: &mut Context<Self>) {
{
let config = cx.global_mut::<Config>();
config.window_opacity = None;
config.window_blur = None;
}
apply_theme(Some(window), cx);
cx.global::<Config>().save();
self.sync_window_opacity_slider(window, cx);
cx.notify();
}
/// Snap the Appearance opacity slider's thumb to the value now in effect.
/// Needed whenever that value changes for a reason other than the user
/// dragging it (theme switch, "Follow theme" reset).
pub(crate) fn sync_window_opacity_slider(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let eff = Self::effective_window_opacity(cx);
if let Some(slider) = self
.active_settings()
.map(|s| s.window_opacity_slider.clone())
{
slider.update(cx, |s, cx| s.set_value(eff, window, cx));
}
}
/// Set the active (editable) theme's background image from a native file
/// picker, keeping the existing image opacity (or the schema default).
pub(crate) fn pick_theme_image(&mut self, cx: &mut Context<Self>) {
let rx = cx.prompt_for_paths(gpui::PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(paths))) = rx.await {
if let Some(path) = paths.into_iter().next() {
let _ = this.update_in(cx, |this, window, cx| {
this.mutate_active_theme(
|theme| {
let opacity =
theme.image.as_ref().map(|i| i.opacity).unwrap_or(0.3);
theme.image = Some(crate::ui::presets::Image { path, opacity });
},
window,
cx,
);
// The editor gains/loses the image-opacity slider with
// the image itself.
this.rebuild_theme_editor(window, cx);
});
}
}
})
.detach();
}
/// Remove the active theme's background image.
pub(crate) fn remove_theme_image(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.mutate_active_theme(|theme| theme.image = None, window, cx);
self.rebuild_theme_editor(window, cx);
}
/// Set the active theme's background-image opacity from the editor slider.
pub(crate) fn set_theme_image_opacity(
&mut self,
v: f32,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.mutate_active_theme(
|theme| {
if let Some(img) = theme.image.as_mut() {
img.opacity = v.clamp(0.0, 1.0);
}
},
window,
cx,
);
}
/// (Re)build the settings tab's color-editor pickers for the current active
/// theme. If no settings tab is open or the active theme isn't an editable
/// file, the editor is cleared. Called after every theme switch / duplicate
/// and when opening settings, so the pickers always reflect (and target) the
/// theme currently on screen.
pub(crate) fn rebuild_theme_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.is_none() {
return;
}
let id = crate::ui::theme::effective_preset_id(cx);
let theme = crate::ui::presets::by_id(cx, &id);
if !theme.editable() {
if let Some(s) = self.settings.as_mut() {
s.theme_editor = None;
}
return;
}
let neutrals = theme.neutrals();
// (edit target, row label, current 0xRRGGBB value) for each seed color.
let seed_specs: [(ThemeEdit, &str, u32); 5] = [
(
ThemeEdit::Background,
"Background",
theme.background_color(),
),
(ThemeEdit::Foreground, "Foreground", theme.foreground),
(ThemeEdit::Accent, "Accent", theme.accent),
(
ThemeEdit::Cursor,
"Cursor",
theme.caret.unwrap_or(theme.accent),
),
(ThemeEdit::Selection, "Selection", neutrals.selection),
];
let mut subs = Vec::new();
let mut make =
|edit: ThemeEdit, value: u32, subs: &mut Vec<Subscription>, cx: &mut Context<Self>| {
let eff: gpui::Hsla = gpui::rgb(value).into();
let state = cx.new(|cx| ColorPickerState::new(window, cx).default_value(eff));
subs.push(cx.subscribe_in(
&state,
window,
move |this, _picker, ev: &ColorPickerEvent, window, cx| {
let ColorPickerEvent::Change(value) = ev;
if let Some(v) = value {
this.edit_active_theme(edit, *v, window, cx);
}
},
));
state
};
let seed = seed_specs
.iter()
.map(|&(edit, label, value)| {
(edit, label.to_string(), make(edit, value, &mut subs, cx))
})
.collect();
let ansi = (0..16)
.map(|i| {
let (r, g, b) = theme.ansi16[i];
let value = (r as u32) << 16 | (g as u32) << 8 | b as u32;
(
ThemeEdit::Ansi(i),
format!("Color {i}"),
make(ThemeEdit::Ansi(i), value, &mut subs, cx),
)
})
.collect();
// Background-image opacity slider, present only while the theme has an
// image (choosing/removing one rebuilds the editor). Emits `Change`
// continuously while dragging; each tick writes the theme file and
// repaints, so the mix is live under the thumb.
let image_opacity_slider = theme.image.as_ref().map(|img| {
let slider = cx.new(|_| {
SliderState::new()
.min(0.0)
.max(1.0)
.step(0.01)
.default_value(img.opacity)
});
subs.push(cx.subscribe_in(
&slider,
window,
|this, _s, ev: &SliderEvent, window, cx| {
if let SliderEvent::Change(v) = ev {
this.set_theme_image_opacity(v.start(), window, cx);
}
},
));
slider
});
if let Some(s) = self.settings.as_mut() {
s.theme_editor = Some(ThemeEditor {
for_id: theme.id.clone(),
seed,
ansi,
image_opacity_slider,
_subs: subs,
});
}
}
/// Toggle terminal font ligatures through the generic `font_features`
/// config. On enables the common programming-font features; off restores
/// tty7's terminal-safe default (contextual ligatures disabled).
pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context<Self>) {
let features = on.then(|| {
gpui::FontFeatures(Arc::new(vec![
("calt".to_string(), 1),
("liga".to_string(), 1),
]))
});
self.font_features = features.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let features = features.clone();
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_features = features;
cfg.save();
cx.notify();
}
fn apply_terminal_config_to_panes(&self, config: &Config, cx: &mut Context<Self>) {
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.terminal.apply_user_config(config);
cx.notify();
});
}
}
}
/// Switch the default cursor shape, update each pane's terminal defaults,
/// and repaint. App-requested DECSCUSR shapes still override this at runtime.
pub(crate) fn set_cursor_style(&mut self, style: ConfigCursorStyle, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.cursor_style = style);
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
// ── Config setters (Terminal / Window & Tabs / Cursor settings) ─────────
// Each goes through `update_config` (mutate the global, persist, repaint).
// Effect points read the global live (blink task, `poll_foreground`, link
// gates, `new_tab_insert_at`), so there's nothing to push into the panes —
// except cursor blink, which must un-hide a cursor a prior blink cycle may
// have left dark.
/// Shared tail of every config setter: mutate the global `Config`, persist
/// it, and repaint so the control reflects the new value. Keeping the
/// persist/notify contract here means a future change (e.g. debounced
/// saves) lands in one place.
pub(crate) fn update_config(
&mut self,
cx: &mut Context<Self>,
mutate: impl FnOnce(&mut Config),
) {
let cfg = cx.global_mut::<Config>();
mutate(cfg);
cfg.save();
cx.notify();
}
pub(crate) fn set_link_url(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.link_url = on);
}
pub(crate) fn set_ssh_loopback_forward(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.ssh_loopback_forward = on);
}
/// Global default for native-SSH host-key verification (WS3, FR-S4). A
/// per-profile override still wins where set.
pub(crate) fn set_verify_host_keys(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.verify_host_keys = on);
}
/// Global default for confirming before closing a live SSH session (FR-E3).
/// A per-profile `warn_on_close` override still wins where set.
pub(crate) fn set_ssh_warn_on_close(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
}
/// Refresh the managed (Local/Remote/Dynamic) forwards for `pane_id` (WS4).
pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context<Self>) {
self.loopback_panel.managed = crate::terminal::RemoteTerminal::list_forwards(pane_id);
cx.notify();
}
/// Pick the kind for the add-forward form (native-SSH panes).
pub(crate) fn set_managed_forward_kind(
&mut self,
kind: crate::daemon::protocol::SshForwardKind,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_kind = kind;
cx.notify();
}
/// Establish the add-form's managed forward on `pane_id`'s connection, then
/// clear the form. A blank/invalid bind port is ignored; Dynamic forwards need
/// no target.
pub(crate) fn add_managed_forward(
&mut self,
pane_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) {
use crate::daemon::protocol::{SshForwardKind, SshForwardRule};
let kind = self.loopback_panel.mf_kind;
let bind_host = self
.loopback_panel
.mf_bind_host
.read(cx)
.value()
.trim()
.to_string();
let bind_host = if bind_host.is_empty() {
"127.0.0.1".to_string()
} else {
bind_host
};
let Ok(bind_port) = self
.loopback_panel
.mf_bind_port
.read(cx)
.value()
.trim()
.parse::<u16>()
else {
return;
};
let target_host = self
.loopback_panel
.mf_target_host
.read(cx)
.value()
.trim()
.to_string();
let target_port = self
.loopback_panel
.mf_target_port
.read(cx)
.value()
.trim()
.parse::<u16>()
.unwrap_or(0);
// Local/Remote require a target; Dynamic (SOCKS) does not.
if kind != SshForwardKind::Dynamic && (target_host.is_empty() || target_port == 0) {
return;
}
let description = self
.loopback_panel
.mf_description
.read(cx)
.value()
.trim()
.to_string();
let rule = SshForwardRule {
kind,
bind_host,
bind_port,
target_host,
target_port,
description: (!description.is_empty()).then_some(description),
};
// Editing an existing forward = re-establish it: drop the old one first so
// its listener frees the (possibly reused) bind port before the new one binds.
if let Some(old_id) = self.loopback_panel.mf_editing.take() {
let _ = crate::terminal::RemoteTerminal::remove_forward(pane_id, old_id);
}
self.loopback_panel.managed = crate::terminal::RemoteTerminal::add_forward(pane_id, rule);
// Reset the value-carrying fields; keep bind host default.
for input in [
&self.loopback_panel.mf_bind_port,
&self.loopback_panel.mf_target_host,
&self.loopback_panel.mf_target_port,
&self.loopback_panel.mf_description,
] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
cx.notify();
}
/// Load an existing forward's values into the add form for editing
/// (VSCode-style: change the port/target, Save re-establishes it).
pub(crate) fn edit_managed_forward(
&mut self,
forward: crate::daemon::protocol::ManagedForward,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_kind = forward.kind;
self.loopback_panel.mf_editing = Some(forward.id);
let target_port = if forward.target_port == 0 {
String::new()
} else {
forward.target_port.to_string()
};
let fields: [(&Entity<InputState>, String); 5] = [
(&self.loopback_panel.mf_bind_host, forward.bind_host.clone()),
(
&self.loopback_panel.mf_bind_port,
forward.bind_port.to_string(),
),
(
&self.loopback_panel.mf_target_host,
forward.target_host.clone(),
),
(&self.loopback_panel.mf_target_port, target_port),
(
&self.loopback_panel.mf_description,
forward.description.clone().unwrap_or_default(),
),
];
for (input, value) in fields {
input.update(cx, |input, cx| input.set_value(&value, window, cx));
}
cx.notify();
}
/// Leave edit mode without saving; clear the form back to the add defaults.
pub(crate) fn cancel_managed_forward_edit(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.loopback_panel.mf_editing = None;
for input in [
&self.loopback_panel.mf_bind_port,
&self.loopback_panel.mf_target_host,
&self.loopback_panel.mf_target_port,
&self.loopback_panel.mf_description,
] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
self.loopback_panel
.mf_bind_host
.update(cx, |input, cx| input.set_value("127.0.0.1", window, cx));
cx.notify();
}
/// Tear down one managed forward by id (native-SSH panes).
pub(crate) fn remove_managed_forward(
&mut self,
pane_id: u64,
forward_id: u64,
cx: &mut Context<Self>,
) {
self.loopback_panel.managed =
crate::terminal::RemoteTerminal::remove_forward(pane_id, forward_id);
cx.notify();
}
pub(crate) fn toggle_loopback_forward_panel(&mut self, pane_id: u64, cx: &mut Context<Self>) {
let should_open = self.loopback_panel.open_pane_id != Some(pane_id);
if should_open {
self.loopback_panel.open_pane_id = Some(pane_id);
self.refresh_managed_forwards(pane_id, cx);
} else {
self.loopback_panel.open_pane_id = None;
}
cx.notify();
}
pub(crate) fn close_loopback_forward_panel(&mut self, cx: &mut Context<Self>) {
self.loopback_panel.open_pane_id = None;
cx.notify();
}
/// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/
/// §3.3). The input is parsed as best-effort into a transient profile — a
/// `user@host[:port]` target plus the trivially-mappable flags (`-p`, `-i`,
/// `-l`, `-J`, `-o User=`/`-o Port=`). A line that can't be parsed into a host
/// surfaces a diagnosable inline notice rather than silently shelling out.
fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) {
match parse_ssh_connect_input(input) {
Ok(parsed) => {
// `ssh` semantics: a target naming a `~/.ssh/config` alias
// resolves through it, with typed flags overriding the config's
// values. (After parsing, a port of 22 is indistinguishable
// from "not given", so an explicit `-p 22` can't override a
// config port — the one caveat of this overlay.)
let (profile, proxy_jump) =
match ssh_config::resolve_alias_to_profile(&parsed.profile.host) {
Some(resolved) => {
let mut p = resolved.profile;
if !parsed.profile.user.is_empty() {
p.user = parsed.profile.user;
}
if parsed.profile.port != 22 {
p.port = parsed.profile.port;
}
if !parsed.profile.identity_files.is_empty() {
p.identity_files = parsed.profile.identity_files;
}
(p, parsed.proxy_jump.or(resolved.proxy_jump))
}
None => (parsed.profile, parsed.proxy_jump),
};
let verify = cx.global::<Config>().verify_host_keys;
let spec = crate::ui::ssh_connect::native_spec_from_transient_profile(
&profile,
proxy_jump,
&crate::core::keychain::OsCredentialStore,
verify,
&crate::ui::ssh_connect::config_alias_resolver,
);
self.open_native_ssh_tab(Box::new(spec), window, cx);
}
Err(reason) => self.push_ssh_connect_error(reason, cx),
}
}
/// Toggle the startup update check (Settings → About). Takes effect on the
/// next launch — this only persists the preference; it doesn't run or cancel
/// an in-flight check.
pub(crate) fn set_check_for_updates(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.check_for_updates = on);
}
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.cursor_blink = on);
// Turning blink off mid-cycle could leave the cursor in its hidden phase;
// force every pane's cursor back on so it doesn't stick invisible.
if !on {
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.cursor_visible = true;
cx.notify();
});
}
}
}
}
pub(crate) fn set_scrollback_limit(&mut self, lines: usize, cx: &mut Context<Self>) {
// Callers pass fixed in-range presets, but clamp anyway so a future caller
// can't smuggle in a degenerate value.
self.update_config(cx, |cfg| {
cfg.scrollback_limit = lines.clamp(100, crate::core::config::MAX_SCROLLBACK)
});
let cfg = cx.global::<Config>().clone();
self.terminal_cursor_style = cfg.cursor_style;
self.terminal_scrollback_limit = cfg.scrollback_limit;
self.apply_terminal_config_to_panes(&cfg, cx);
}
pub(crate) fn set_new_tab_position(&mut self, pos: NewTabPosition, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.new_tab_position = pos);
}
/// Set where the tab bar is rendered (Settings → Window & Tabs). Persists the
/// choice; the layout re-derives from the `Config` global on the next render.
pub(crate) fn set_tab_bar_position(&mut self, pos: TabBarPosition, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.tab_bar_position = pos);
}
/// Set how the vertical tab sidebar arranges its rows (Settings → Window &
/// Tabs): grouped per git repo or one flat list. Persists the choice; the
/// sidebar re-derives from the `Config` global on the next render.
pub(crate) fn set_sidebar_grouping(
&mut self,
grouping: crate::core::config::SidebarGrouping,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping);
}
/// `ToggleTabSidebar`: flip the tab bar between the horizontal title-bar strip
/// (`Top`) and the vertical left sidebar (`Left`), persisting the choice.
pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<Config>().tab_bar_position {
TabBarPosition::Top => TabBarPosition::Left,
TabBarPosition::Left => TabBarPosition::Top,
};
self.set_tab_bar_position(next, cx);
}
pub(crate) fn set_notify_mode(
&mut self,
mode: crate::core::config::NotifyMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.notify_on_command_finish = mode);
}
/// Set the "long command" floor (seconds) a foreground command must exceed
/// to be eligible for a completion notification. Read live where the alert
/// is posted, so nothing needs pushing to open panes.
pub(crate) fn set_notify_threshold(&mut self, secs: u64, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.notify_threshold_secs = secs.clamp(1, 3600));
}
/// Switch how the terminal bell is signalled. Read live in each pane's bell
/// handler, so there's nothing to push.
pub(crate) fn set_bell_mode(
&mut self,
mode: crate::core::config::BellMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.bell = mode);
}
/// Toggle session restore. Takes effect on the next launch (this only
/// persists the preference); the current window is untouched.
pub(crate) fn set_restore_session(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.restore_session = on);
}
/// Toggle the system tray icon. The tray's poll loop re-reads the flag
/// every second, so the icon appears/disappears without a restart.
pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.show_tray_icon = on);
}
// ── Input / Mouse setters ───────────────────────────────────────────────
/// Takes effect on the next keystroke — the terminal reads the flag per
/// key event, so nothing needs pushing to open panes.
pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.macos_option_as_alt = on);
}
pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on);
// Push the new policy to GPUI right away (same call the hot-reload uses).
crate::ui::theme::apply_cursor_hide_mode(cx);
}
pub(crate) fn set_focus_follows_mouse(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.focus_follows_mouse = on);
}
/// Toggle whether mouse events reach full-screen apps. The gates are cached
/// per view, so this pushes the new value into every open pane (like the
/// font setters) in addition to persisting it.
pub(crate) fn set_mouse_reporting(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.mouse_reporting = on);
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.report_mouse = on;
cx.notify();
});
}
}
}
pub(crate) fn set_mouse_scroll_multiplier(&mut self, mult: f32, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.mouse_scroll_multiplier = mult.clamp(0.1, 10.0)
});
}
pub(crate) fn set_clipboard_trim(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on);
}
pub(crate) fn set_copy_on_select(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.copy_on_select = on);
}
pub(crate) fn set_startup_mode(
&mut self,
mode: crate::core::config::StartupMode,
cx: &mut Context<Self>,
) {
self.update_config(cx, |cfg| cfg.startup_mode = mode);
}
pub(crate) fn set_remember_window_size(&mut self, on: bool, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.remember_window_size = on);
}
pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) {
// While the settings overlay is open it owns focus (so Esc-to-close and
// keybinding capture keep working); tab operations behind it don't steal
// it. `close_settings` refocuses the active terminal on the way out.
if let Some(settings) = self.settings.as_ref() {
window.focus(&settings.focus_handle, cx);
return;
}
let Some(tab) = self.tabs.get(self.active) else {
// No tabs → the home page is showing; keep something focused so
// keystrokes stay on the window's dispatch path (⌘T etc. must still
// reach the root action handlers).
window.focus(&self.home_focus, cx);
return;
};
// A tab showing its diff overlay gives the overlay focus (Esc-to-close
// must keep working when switching back to it); `close_diff_overlay`
// re-runs this after clearing the slot to land on the terminal.
if let Some(overlay) = tab.diff_overlay.as_ref() {
window.focus(&overlay.focus_handle, cx);
return;
}
if let Some(leaf) = tab.focus_target() {
let handle = leaf.read(cx).focus_handle.clone();
window.focus(&handle, cx);
}
}
/// Snapshot which pane currently holds focus in the active tab into that
/// tab's `last_focused`, so `focus_active` can restore it when we come back.
/// Call this before any transition that moves focus off the active tab
/// (switching tabs, opening a focus-stealing overlay).
pub(crate) fn remember_active_pane(&mut self, window: &Window, cx: &App) {
let active = self.active;
if let Some(tab) = self.tabs.get_mut(active) {
if let Some(leaf) = tab.pane.focused_leaf(window, cx) {
tab.last_focused = Some(leaf.entity_id());
}
}
}
fn focus_leaf(&self, leaf: &Entity<TerminalView>, window: &mut Window, cx: &mut App) {
let handle = leaf.read(cx).focus_handle.clone();
window.focus(&handle, cx);
}
/// Where a freshly opened tab should be inserted, per `new_tab_position`:
/// right after the active tab, or appended at the end. Clamped to the tab
/// count so the zero-tab home state (active 0, no tabs) inserts at 0.
fn new_tab_insert_at(&self, cx: &App) -> usize {
match cx.global::<Config>().new_tab_position {
NewTabPosition::AfterCurrent => (self.active + 1).min(self.tabs.len()),
NewTabPosition::End => self.tabs.len(),
}
}
pub(crate) fn new_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.new_tab_with_shell(None, window, cx);
}
/// Open a new tab running `shell` — a pick from the "+" dropdown — or the
/// default shell when `None` (the plain "+" click / Cmd+T path).
pub(crate) fn new_tab_with_shell(
&mut self,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
// Inherit the cwd of the active tab's focused terminal so the new tab
// opens in the same directory the user is currently working in. Local
// cwds only: the new tab is a local shell, and an inherited cwd wins
// over every fallback in `pane::initial_working_directory`, so a remote
// path would be handed straight to the spawn as a working directory.
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
});
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
// Leaving the current tab for the new one; snapshot its focused pane
// so switching back restores it (same as `activate`).
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(insert_at, Tab::new(Pane::leaf(tab)));
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Open a new tab running a native (russh) SSH session for the resolved
/// `spec` (PRD FR-C1). The caller (`ui::ssh_connect`) has already pulled any
/// keychain secrets into `spec`. Mirrors `new_tab_with_shell` but for the
/// native backend.
pub(crate) fn open_native_ssh_tab(
&mut self,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = self.tabs.get(self.active).and_then(|t| {
t.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
});
let view = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH spawn failed: {e}");
window.push_notification(format!("SSH connection failed: {e}"), cx);
return;
}
};
// Leaving the current tab for the new one; snapshot its focused pane
// so switching back restores it (same as `activate`).
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
self.tabs.insert(insert_at, Tab::new(Pane::leaf(view)));
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Respawn a native SSH pane **in place** (same tab / split slot), replacing a
/// dead pane's view with a fresh native connection for `spec` (PRD FR-E4). The
/// daemon re-establishes the profile's preconfigured forwards on connect.
pub(crate) fn respawn_native_ssh_in_place(
&mut self,
dead: &Entity<TerminalView>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cwd = dead.read(cx).cwd();
let fresh = match new_terminal_native(self.font_size, cwd, spec, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH respawn failed: {e}");
window.push_notification(format!("SSH reconnect failed: {e}"), cx);
return;
}
};
// Swap the fresh leaf into the dead one's position across every tab.
for tab in &mut self.tabs {
if tab.pane.replace_leaf(dead, fresh.clone()) {
break;
}
}
self.maximized = None;
self.focus_leaf(&fresh, window, cx);
self.save_session(cx);
cx.notify();
}
/// Split the focused pane in the active tab, focusing the new terminal.
pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) {
// Capture the target leaf BEFORE creating the new terminal: constructing
// a TerminalView focuses it, which would otherwise make us lose track of
// which pane to split (nested splits would always hit the first leaf).
let Some(target) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
// The new pane inherits the cwd — and the shell, when the pane being
// split was opened with an explicit pick (a WSL/fish tab splits into
// more WSL/fish, not back to the default). Local cwds only: the
// native-SSH branch below has the daemon discard it regardless, and the
// local branch would otherwise spawn against a remote path.
let cwd = target.read(cx).local_cwd();
// Splitting a native-SSH pane opens another SSH pane on the same
// connection rather than dropping back to a local shell. Re-resolve the
// persisted (secret-free) spec from its saved profile so keychain
// secrets are re-applied, mirroring the reconnect path.
let ssh_spec = target.read(cx).ssh_spec();
let new = if let Some(spec) = ssh_spec {
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
match new_terminal_native(self.font_size, cwd, resolved, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("native SSH split spawn failed: {e}");
window.push_notification(format!("SSH connection failed: {e}"), cx);
return;
}
}
} else {
let shell = target.read(cx).shell_spec();
new_terminal(self.font_size, cwd, None, shell, window, cx)
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.split_leaf(&target, axis, new.clone()) {
self.maximized = None;
self.focus_leaf(&new, window, cx);
self.save_session(cx);
cx.notify();
}
}
}
/// Close the focused pane. If it was the tab's only pane, close the tab.
fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// FR-E3: if the focused pane is a live SSH session flagged warn-on-close,
// raise the in-pane confirm sheet instead of closing outright.
if self.ssh_close_confirm.is_none() && self.focused_pane_is_warn_ssh(window, cx) {
self.ssh_close_confirm = Some(SshCloseKind::Pane);
cx.notify();
return;
}
self.ssh_close_confirm = None;
self.maximized = None;
// Capture the focused leaf before closing: if a split collapses, that
// leaf is destroyed with no reopen path, so we kill its daemon pane. Owned
// clones from `leaves()` end the borrow before the `&mut` close below.
let focused = self.tabs.get(self.active).and_then(|tab| {
tab.pane
.leaves()
.into_iter()
.find(|l| l.read(cx).focus_handle.contains_focused(window, cx))
});
let outcome = match self.tabs.get_mut(self.active) {
Some(tab) => tab.pane.close_focused(window, cx),
None => return,
};
match outcome {
CloseOutcome::RemoveSelf => {
// The focused leaf *is* the tab's only pane: close the tab, which
// kills its panes itself.
self.close_tab(self.active, window, cx);
}
CloseOutcome::NotFound => {
// No terminal leaf in the active tab holds focus (focus is in the
// rename input / settings / drifted). Only fall back to closing the
// tab when it's a single pane — never silently destroy a multi-pane
// split whose target the user can't see.
let single = self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.leaves().len() <= 1);
if single {
self.close_tab(self.active, window, cx);
}
}
CloseOutcome::Collapsed => {
if let Some(leaf) = &focused {
crate::terminal::RemoteTerminal::kill_pane(leaf.read(cx).pane_id);
}
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
}
}
/// Close the pane whose shell just exited on its own (`ChildExited` from
/// the view — `exit`, Ctrl-D, a crashed shell): collapse its split, or
/// close its tab when it was the only pane. Unlike `close_pane` this
/// targets the *emitting* leaf, not the focused one — the exit can happen
/// in a background tab. The daemon pane is killed even though its child is
/// already dead: the daemon still lists it for reattach, and killing is
/// what drops it from the session.
fn on_child_exited(
&mut self,
view: Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = view.entity_id();
let Some(index) = self
.tabs
.iter()
.position(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == id))
else {
return; // already closed (e.g. by the user racing the exit)
};
// A native-SSH pane lingers instead of closing (PRD FR-C2/E4): a failed
// connect's diagnostic must stay readable, and a dropped session's pane
// is the anchor for the in-pane reconnect (`restart_ssh_session`) —
// auto-close would make both unreachable. Only local shells fall through
// to the close below.
if view.read(cx).ssh_disconnected() {
cx.notify();
return;
}
match self.tabs[index].pane.close_leaf(&view) {
// The exited pane was the tab's only leaf: close the whole tab
// (which snapshots it for reopen and kills its daemon panes).
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
// Unreachable — containment was just checked — but never close a
// tab we failed to locate the leaf in.
CloseOutcome::NotFound => {}
CloseOutcome::Collapsed => {
crate::terminal::RemoteTerminal::kill_pane(view.read(cx).pane_id);
if index == self.active {
self.maximized = None;
self.focus_active(window, cx);
}
self.save_session(cx);
cx.notify();
}
}
}
/// Cycle focus among the panes of the active tab.
fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
// `leaves()` returns owned clones, so the immutable borrow of `self.tabs`
// ends here — letting us mutate `self.maximized` just below.
let leaves = match self.tabs.get(self.active) {
Some(tab) => tab.pane.leaves(),
None => return,
};
if leaves.len() < 2 {
return;
}
self.maximized = None;
let current = leaves
.iter()
.position(|l| l.read(cx).focus_handle.contains_focused(window, cx))
.unwrap_or(0);
let next = if forward {
(current + 1) % leaves.len()
} else {
(current + leaves.len() - 1) % leaves.len()
};
let leaf = leaves[next].clone();
self.focus_leaf(&leaf, window, cx);
cx.notify();
}
/// Move focus to the pane adjacent to the focused one in `dir` (tmux
/// directional focus). A no-op when there's no neighbor that way.
fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self
.tabs
.get(self.active)
.and_then(|tab| tab.pane.neighbor_in_dir(dir, window, cx))
else {
return;
};
self.maximized = None;
self.focus_leaf(&target, window, cx);
cx.notify();
}
/// Grow/shrink the focused pane along `dir` by one step, adjusting its
/// nearest matching-axis split. Persists the new layout. A no-op when no
/// split matches (e.g. a single-pane tab, or no divider on that axis).
fn resize_pane(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) {
let changed = self
.tabs
.get(self.active)
.is_some_and(|tab| tab.pane.resize_focused_pane(dir, RESIZE_STEP, window, cx));
if changed {
self.save_session(cx);
cx.notify();
}
}
/// Swap the focused pane with its next / previous sibling in leaf order
/// (tmux `prefix }` / `prefix {`). The terminals trade tree positions;
/// focus rides along with the moved terminal. Needs at least two panes.
fn swap_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let (from, len) = match self.tabs.get(self.active) {
Some(tab) => (tab.pane.focused_index(window, cx), tab.pane.leaves().len()),
None => return,
};
if len < 2 {
return;
}
let from = from.unwrap_or(0);
let to = if forward {
(from + 1) % len
} else {
(from + len - 1) % len
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.swap_leaf_indices(from, to) {
self.maximized = None;
self.save_session(cx);
cx.notify();
}
}
}
/// Switch to the next / previous tab, wrapping around (tmux `prefix n/p`).
/// A no-op with fewer than two tabs.
fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let n = self.tabs.len();
if n < 2 {
return;
}
let next = if forward {
(self.active + 1) % n
} else {
(self.active + n - 1) % n
};
self.activate(next, window, cx);
}
pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
if index < self.tabs.len() && index != self.active {
// Remember the pane we're leaving focused so returning to this tab
// restores it instead of jumping to the first leaf.
self.remember_active_pane(window, cx);
self.maximized = None;
self.active = index;
// The incoming tab may have a diff overlay that went stale while
// hidden (its repo changed underneath); re-probe if the status
// cache disagrees with the shown snapshot.
self.maybe_refresh_diff_overlay(cx);
// In sidebar mode, pull the newly active row into view (a no-op when
// the strip is horizontal — the handle tracks no painted list then).
self.sidebar_scroll.scroll_to_item(index);
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
}
/// Toggle maximize on the active tab's focused pane (Cmd+Shift+Enter). When a
/// pane is maximized the tab renders only that leaf full-window; toggling again
/// (or any structural change) restores the split layout. A no-op when the
/// active tab has a single pane (nothing to maximize).
fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.maximized.is_some() {
self.maximized = None;
self.focus_active(window, cx);
cx.notify();
return;
}
let Some(tab) = self.tabs.get(self.active) else {
return;
};
if tab.pane.leaves().len() < 2 {
return;
}
let leaf = tab.pane.focused_or_first(window, cx);
if let Some(leaf) = leaf {
let handle = leaf.read(cx).focus_handle.clone();
self.maximized = Some(leaf);
window.focus(&handle, cx);
cx.notify();
}
}
pub(crate) fn close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
// Closing the last tab is allowed: zero tabs is the home page (see
// `ui::home`), and `focus_active`/`render` both handle it.
if index >= self.tabs.len() {
return;
}
// FR-E3: confirm before closing a tab that holds a live warn-on-close SSH
// session (unless this call is the confirmation itself).
let already_confirming = self.ssh_close_confirm == Some(SshCloseKind::Tab(index));
if !already_confirming && self.tab_has_warn_ssh(index, cx) {
self.ssh_close_confirm = Some(SshCloseKind::Tab(index));
cx.notify();
return;
}
self.ssh_close_confirm = None;
self.maximized = None;
// A rename in progress stores a fixed tab index; removing a tab shifts
// indices and would let the pending edit commit onto the wrong tab. Drop it.
self.renaming = None;
// Capture the tab's cwd *before* its panes are killed (the daemon can't
// report it afterwards): if it sat in a tty7-managed worktree, the
// cleanup offer below needs it.
let worktree_cwd = self.tab_local_cwd(index, window, cx);
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back.
let snapshot = tab_to_session(&self.tabs[index], cx);
self.closed.push(snapshot);
if self.closed.len() > MAX_CLOSED_TABS {
self.closed.remove(0);
}
// Explicitly closing a tab kills its daemon panes (matching the old
// in-process behavior: closing ends the shells). This is distinct from
// *quitting* the app, where panes are detached and kept alive so the
// next launch can re-attach. Reopen-closed-tab then spawns fresh in the
// saved cwd, just like before the daemon split.
for leaf in self.tabs[index].pane.leaves() {
crate::terminal::RemoteTerminal::kill_pane(leaf.read(cx).pane_id);
}
self.tabs.remove(index);
if self.tabs.is_empty() {
// Home page: keep `active` at a stable 0 (every access goes through
// `tabs.get`, which yields None until a tab exists again).
self.active = 0;
} else if self.active >= self.tabs.len() {
self.active = self.tabs.len() - 1;
} else if index < self.active {
self.active -= 1;
}
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
// The tab is gone; if it lived in a tty7-managed worktree, offer to
// clean the checkout up rather than letting them pile up silently.
self.offer_worktree_cleanup(worktree_cwd, cx);
}
/// After closing a tab that sat in a tty7-managed worktree (see
/// [`crate::core::worktree::managed`]), offer to remove the checkout: a
/// clean worktree gets a plain keep/remove prompt; one with uncommitted
/// changes defaults to keeping and makes discarding explicit. Removal also
/// deletes the branch when it carries no unmerged commits (`branch -d`).
/// No offer while any surviving pane still has its cwd inside the checkout
/// (new tabs inherit the current cwd, so shared worktrees are common) —
/// removal would yank the directory out from under a live shell.
/// Detection, the dirty probe, and removal all run off the UI thread.
fn offer_worktree_cleanup(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
let Some(cwd) = cwd else { return };
// Every leaf of every surviving tab, not just focused panes — a shell
// tucked away in a split occupies the worktree all the same.
// Local paths only: this list is what stops a worktree being removed
// out from under a live shell, and a remote cwd can neither occupy a
// local worktree nor be compared against one meaningfully.
let open_cwds: Vec<std::path::PathBuf> = self
.tabs
.iter()
.flat_map(|tab| tab.pane.leaves())
.filter_map(|leaf| leaf.read(cx).local_cwd())
.collect();
cx.spawn(async move |this, cx| {
let Some(wt) = cx
.background_spawn(async move {
crate::core::worktree::managed(&cwd)
.filter(|wt| !crate::core::worktree::occupied(&wt.path, &open_cwds))
})
.await
else {
return;
};
let detail = if wt.dirty {
format!(
"The closed tab's worktree at {} has uncommitted changes.",
wt.path.display()
)
} else {
format!(
"The closed tab's worktree at {} is clean.",
wt.path.display()
)
};
let title = format!("Remove worktree \"{}\"?", wt.branch);
let level = if wt.dirty {
PromptLevel::Warning
} else {
PromptLevel::Info
};
let remove_label = if wt.dirty {
"Discard Changes & Remove"
} else {
"Remove Worktree"
};
let Ok(answer) = this.update_in(cx, |_, window, cx| {
window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx)
}) else {
return;
};
if !matches!(answer.await, Ok(1)) {
return;
}
let force = wt.dirty;
let branch = wt.branch.clone();
let result = cx
.background_spawn(async move { crate::core::worktree::remove(&wt, force) })
.await;
let _ = this.update_in(cx, |_, window, cx| match result {
Ok(()) => window.push_notification(format!("Removed worktree \"{branch}\""), cx),
Err(e) => window.push_notification(format!("Worktree removal failed: {e}"), cx),
});
})
.detach();
}
/// Close every tab except `index` ("Close Other Tabs"). Iterates from the
/// end so removals never shift an index still to visit. Tabs holding a live
/// warn-on-close SSH session are skipped outright — the per-tab confirm
/// sheet is keyed by index, which a bulk close would immediately
/// invalidate — so they simply survive the sweep.
pub(crate) fn close_other_tabs(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if index >= self.tabs.len() {
return;
}
for i in (0..self.tabs.len()).rev() {
if i == index || self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab(i, window, cx);
}
}
/// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs
/// Below" in the sidebar). Same end-first iteration and warn-SSH skip as
/// [`close_other_tabs`](Self::close_other_tabs).
pub(crate) fn close_tabs_right_of(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
for i in ((index + 1)..self.tabs.len()).rev() {
if self.tab_has_warn_ssh(i, cx) {
continue;
}
self.close_tab(i, window, cx);
}
}
/// "Mark as Unread" (tab context menu): re-flag every finished (`Done`)
/// agent turn in the tab as unread, so the avatar's green dot swells back
/// into its count badge until the user next looks at those panes. The
/// active tab's focus target is told the dismissed menu is about to hand
/// focus back to it, so that focus-in doesn't immediately re-read the mark
/// (see `TerminalView::mark_agent_result_unread`).
pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) {
use crate::core::cli_agent::AgentStatus;
let Some(tab) = self.tabs.get(index) else {
return;
};
let refocus = (index == self.active).then(|| tab.focus_target()).flatten();
for leaf in tab.pane.leaves() {
let refocus_incoming = refocus.as_ref() == Some(&leaf);
leaf.update(cx, |view, cx| {
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
view.mark_agent_result_unread(refocus_incoming);
cx.notify();
}
});
}
cx.notify();
}
/// The cwd of the tab's label-driving terminal (focused leaf, else first) —
/// what the tab context menu's "Copy Working Directory" copies and "New
/// Worktree Tab" derives the repo from.
pub(crate) fn tab_cwd(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).cwd())
}
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
/// for the worktree operations, which shell out to a local `git`. "Copy
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
/// pane's remote path is exactly what the user wants there.
fn tab_local_cwd(
&self,
index: usize,
window: &Window,
cx: &App,
) -> Option<std::path::PathBuf> {
self.tabs
.get(index)?
.pane
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
}
/// "New Worktree Tab": probe the repository containing the tab's cwd for
/// defaults (a fresh generated name, the current branch as start point) on
/// the background executor, then open the confirmation sheet
/// (`ui::worktree_prompt`) where name/branch/base can be edited before
/// anything is created. Failure to probe lands as a notification.
pub(crate) fn new_worktree_tab(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
window.push_notification("This tab has no working directory yet", cx);
return;
};
cx.spawn(async move |this, cx| {
let probe_cwd = cwd.clone();
let result = cx
.background_spawn(async move { crate::core::worktree::defaults(&probe_cwd) })
.await;
let _ = this.update_in(cx, |this, window, cx| match result {
Ok(defaults) => this.open_worktree_prompt(cwd, defaults, window, cx),
Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx),
});
})
.detach();
}
/// Open the tab for a just-created worktree: a default-shell terminal in
/// the worktree directory, with the tab pre-named after its branch so a
/// strip of parallel worktrees stays tellable-apart. Mirrors
/// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point).
pub(crate) fn open_worktree_tab(
&mut self,
wt: crate::core::worktree::NewWorktree,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx);
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
let mut tab = Tab::new(Pane::leaf(view));
tab.name = Some(wt.branch);
self.tabs.insert(insert_at, tab);
self.active = insert_at;
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
}
/// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop).
/// Keeps the same tab active across the move and re-persists the session.
pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
return;
}
// Reordering shifts indices; a pending rename keyed on a fixed index would
// commit onto the wrong tab. Drop it.
self.renaming = None;
let was_active = self.active;
let tab = self.tabs.remove(from);
self.tabs.insert(to, tab);
// Re-derive the active index so the same logical tab stays selected:
// removal shifts indices after `from` left, insertion shifts indices at
// or after `to` right.
self.active = if was_active == from {
to
} else {
let mut a = was_active;
if from < a {
a -= 1;
}
if to <= a {
a += 1;
}
a
};
self.save_session(cx);
cx.notify();
}
/// Begin an inline rename of the tab at `index`: spawn a focused text input
/// pre-filled with the current label, committing on Enter or blur.
pub(crate) fn start_rename(
&mut self,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.tabs.get(index).is_none() {
return;
}
let current = self.tab_label(&self.tabs[index], index, Some(&*window), cx);
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
input.update(cx, |state, cx| state.focus(window, cx));
let subs = vec![cx.subscribe_in(
&input,
window,
|this, _input, ev: &InputEvent, window, cx| match ev {
InputEvent::PressEnter { .. } | InputEvent::Blur => this.commit_rename(window, cx),
_ => {}
},
)];
self.renaming = Some(Renaming {
index,
input,
_subs: subs,
});
cx.notify();
}
/// Commit the in-progress rename: a non-empty value becomes the tab's custom
/// name; an empty value clears it (reverting to the title-derived label).
/// Taking `renaming` first makes the focus change below re-entrancy-safe (the
/// input's resulting Blur finds no active rename and returns).
fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(renaming) = self.renaming.take() else {
return;
};
let value = renaming.input.read(cx).value().trim().to_string();
if let Some(tab) = self.tabs.get_mut(renaming.index) {
tab.name = if value.is_empty() { None } else { Some(value) };
}
self.save_session(cx);
self.focus_active(window, cx);
cx.notify();
}
// ----- Command palette -------------------------------------------------
/// Build the full command catalog: the static commands plus one
/// "Switch to Tab: …" entry per open tab (label matches the tab strip).
fn palette_commands(&self, cx: &App) -> Vec<Command> {
let mut commands = Command::base_commands();
// Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row
// connects (natively) on Enter and edits on ⌘⏎ / →.
let cfg = cx.global::<Config>();
let now = crate::core::config::unix_now();
let mut profiles: Vec<&crate::core::ssh_profile::SshProfile> =
cfg.ssh_profiles.iter().collect();
profiles.sort_by(|a, b| {
let score = |p: &crate::core::ssh_profile::SshProfile| {
cfg.ssh_profile_frecency
.get(&p.id)
.map(|u| u.score(now))
.unwrap_or(0.0)
};
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for p in profiles {
let subtitle = crate::core::ssh_profile::to_connect_string(p);
let title = if p.name.is_empty() {
subtitle.clone()
} else {
p.name.clone()
};
commands.push(
Command::new(
format!("SSH: {title}"),
CommandKind::ConnectSavedProfile(p.id),
)
.with_subtitle(subtitle),
);
}
// Saved profiles are the palette's *only* SSH listing: `~/.ssh/config`
// hosts appear here after Settings → SSH → "Import from ~/.ssh/config"
// turns them into profiles, never as a parallel live-discovered source
// (two lists of the same hosts with different behaviors confused more
// than it helped). Typing an alias into "SSH: Add Connection…" still
// resolves it against ssh_config on the spot.
for (i, tab) in self.tabs.iter().enumerate() {
// Skip the active tab — "switch to the tab you're already on" is a
// no-op that only pads the list.
if i == self.active {
continue;
}
let label = self.tab_label(tab, i, None, cx);
commands.push(Command::new(
format!("Switch to Tab: {label}"),
CommandKind::ActivateTab(i),
));
}
commands
}
/// Open the palette if closed, or close it if already open (Cmd+P toggles).
fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.palette.is_some() {
self.close_palette(window, cx);
return;
}
// Build the catalog and hand it to a fresh palette view; it owns the
// search input, filtering and keyboard nav, and emits a `PaletteEvent`
// when the user confirms or dismisses.
let commands = self.palette_commands(cx);
let view = cx.new(|cx| PaletteView::new(commands, window, cx));
self.palette_sub = Some(cx.subscribe_in(&view, window, Self::on_palette_event));
self.palette = Some(view);
cx.notify();
}
/// Run the confirmed command (or just close on dismiss) for the open palette.
fn on_palette_event(
&mut self,
_view: &Entity<PaletteView>,
ev: &PaletteEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match ev {
PaletteEvent::Confirm(kind) => {
let kind = kind.clone();
self.close_palette(window, cx);
self.run_command(kind, window, cx);
}
PaletteEvent::Dismiss => self.close_palette(window, cx),
}
}
/// Close the palette and hand focus back to the active terminal.
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.palette = None;
self.palette_sub = None;
self.focus_active(window, cx);
cx.notify();
}
/// Run a palette command by dispatching to the matching tab/pane operation.
fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context<Self>) {
use CommandKind::*;
match kind {
NewTab => self.new_tab(window, cx),
SplitRight => self.split(Axis::Horizontal, window, cx),
SplitDown => self.split(Axis::Vertical, window, cx),
ClosePane => self.close_pane(window, cx),
NextPane => self.cycle_pane(true, window, cx),
PrevPane => self.cycle_pane(false, window, cx),
FocusPaneLeft => self.focus_pane_dir(Dir::Left, window, cx),
FocusPaneRight => self.focus_pane_dir(Dir::Right, window, cx),
FocusPaneUp => self.focus_pane_dir(Dir::Up, window, cx),
FocusPaneDown => self.focus_pane_dir(Dir::Down, window, cx),
ResizePaneLeft => self.resize_pane(Dir::Left, window, cx),
ResizePaneRight => self.resize_pane(Dir::Right, window, cx),
ResizePaneUp => self.resize_pane(Dir::Up, window, cx),
ResizePaneDown => self.resize_pane(Dir::Down, window, cx),
SwapPaneNext => self.swap_pane(true, window, cx),
SwapPanePrev => self.swap_pane(false, window, cx),
NextTab => self.cycle_tab(true, window, cx),
PrevTab => self.cycle_tab(false, window, cx),
ToggleMaximizePane => self.toggle_maximize(window, cx),
ToggleFullscreen => window.toggle_fullscreen(),
ToggleTabSidebar => self.toggle_tab_sidebar(cx),
ResetFontSize => self.reset_font_size(cx),
FindInTerminal => {
// Open the search bar on the pane focus just returned to (the
// palette closed before we got here, restoring terminal focus).
if let Some(leaf) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.open_search(window, cx));
}
}
ClearTerminal => {
// Same focus story as FindInTerminal: act on the pane the closing
// palette just handed focus back to.
if let Some(leaf) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.clear_scrollback(cx));
}
}
ReopenClosedTab => self.reopen_closed_tab(window, cx),
OpenSettings => self.toggle_settings(window, cx),
RestartDaemon => self.restart_daemon(window, cx),
ToggleSftp => self.toggle_sftp(window, cx),
RestartSshSession => self.restart_ssh_session(window, cx),
SetTheme(i) => {
if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) {
self.set_preset(&id, window, cx);
}
}
OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx),
ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx),
EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx),
QuickConnect(target) => {
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
self.quick_connect(qc, window, cx);
}
}
SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx),
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
SendSelectionToAgent => self.send_selection_to_agent(window, cx),
SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
// Handled inside `PaletteView` (opens a sub-list); these never emit a
// `Confirm` for this variant, so they never reach here.
OpenThemePicker | OpenSshConnectInput => {}
ActivateTab(i) => self.activate(i, window, cx),
}
}
// ----- Agent context feed (palette: "Agent: …") -------------------------
/// The pane the agent-feed commands deliver to: the first leaf running a
/// recognized coding agent, preferring the active tab, then any tab. `None`
/// when no agent runs anywhere.
fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> {
let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some();
if let Some(tab) = self.tabs.get(self.active)
&& let Some(leaf) = tab.pane.leaves().into_iter().find(runs_agent)
{
return Some(leaf);
}
self.tabs
.iter()
.enumerate()
.filter(|(i, _)| *i != self.active)
.flat_map(|(_, t)| t.pane.leaves())
.find(runs_agent)
}
/// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to
/// the front so the user sees the turn start. Toasts when no agent runs.
fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(
Some("tty7"),
"No running coding agent found — start one (claude, codex, …) in a pane first.",
);
return;
};
target.read(cx).send_agent_prompt(prompt);
if let Some(i) = self
.tabs
.iter()
.position(|t| t.pane.leaves().contains(&target))
{
self.activate(i, window, cx);
}
}
/// "Agent: Send Selection" — the focused pane's selection, phrased as a
/// prompt, into the running agent's pane (the context-feed idea).
fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let source = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx));
let (selection, cwd) = match &source {
Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()),
None => (None, None),
};
let Some(selection) = selection else {
crate::terminal::notify_desktop(
Some("tty7"),
"Nothing selected — select some terminal output first.",
);
return;
};
let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
if let Some(prompt) =
crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref())
{
self.deliver_agent_prompt(&prompt, window, cx);
}
}
/// "Agent: Send Git Diff for Review" — the focused pane's repo diff
/// (unstaged + staged), phrased as a review prompt, into the agent's pane.
fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let cwd = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
// Local `git` shell-out, so a remote pane's cwd is not usable —
// it reports "no known directory" rather than silently diffing
// whatever the path collides with locally.
.and_then(|view| view.read(cx).local_cwd());
let Some(cwd) = cwd else {
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
return;
};
// Unstaged + staged, concatenated — "everything not yet committed",
// which is what a review pass wants. Both invocations are quick; the
// prompt builder caps runaway diffs.
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&cwd)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
};
let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
let cwd_s = cwd.to_string_lossy().into_owned();
match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
Some(prompt) => self.deliver_agent_prompt(&prompt, window, cx),
None => crate::terminal::notify_desktop(
Some("tty7"),
&format!("No uncommitted changes in {cwd_s} (or not a git repository)."),
),
}
}
// ----- Settings tab (Cmd+,) -------------------------------------------
/// Toggle the settings overlay (Cmd+,). If it's already open, close it;
/// otherwise assemble its widget state (each control pre-filled from config,
/// with its subscriptions pushed onto `subs`) and focus the page.
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.is_some() {
self.close_settings(window, cx);
return;
}
// Settings is about to steal focus; snapshot the active pane so closing
// it lands back on the same terminal rather than the tab's first leaf.
self.remember_active_pane(window, cx);
let focus_handle = cx.focus_handle();
let mut subs = Vec::new();
let (font_select, font_bold_select, font_italic_select) =
self.build_font_selects(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
// Live filter for the theme picker panel; each keystroke re-renders the
// (already-cheap) list so results narrow as you type.
let theme_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search themes…"));
subs.push(
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
}),
);
// Live filter for the nav-header settings search; each keystroke re-renders
// the (cheap) nav rail so the result list narrows as you type.
let settings_search =
cx.new(|cx| InputState::new(window, cx).placeholder("Search settings…"));
subs.push(
cx.subscribe_in(&settings_search, window, |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
this.autoselect_settings_search(cx);
cx.notify();
}
}),
);
self.settings = Some(SettingsState {
focus_handle: focus_handle.clone(),
section: SettingsSection::Appearance,
search: settings_search,
font_select,
font_bold_select,
font_italic_select,
shell_program_input,
shell_args_input,
wd_path_input,
scroll_slider,
window_opacity_slider,
theme_editor: None,
theme_panel_open: false,
theme_panel_slot: crate::ui::settings::ThemeSlot::Manual,
theme_search,
recording: None,
rebinding_note: None,
ssh_form: None,
ssh_detail: crate::ui::settings::SshDetail::None,
agent_hooks_states: Self::agent_hooks_snapshot(),
agent_hooks_note: None,
_subs: subs,
});
// Land the caret in the search box so Settings opens ready to type/filter
// (a blinking cursor), rather than on the inert page root. Escape still
// closes — the root's key handler is an ancestor of the focused input.
let search_focus = self
.settings
.as_ref()
.map(|s| s.search.read(cx).focus_handle(cx));
match search_focus {
Some(handle) => window.focus(&handle, cx),
None => window.focus(&focus_handle, cx),
}
// Build the color editor if we opened straight onto an editable theme.
self.rebuild_theme_editor(window, cx);
cx.notify();
}
/// Primary / bold / italic font-family pickers, seeded from config.
fn build_font_selects(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
Entity<SelectState<SearchableVec<String>>>,
) {
let cfg = cx.global::<Config>();
let family = cfg.font_family.clone();
let font_bold = cfg.font_family_bold.clone();
let font_italic = cfg.font_family_italic.clone();
// Every font the OS reports is selectable — we don't get to decide
// that for the user. The picker's dropdown just caps its own height
// (see `menu_max_h` in settings.rs) so browsing the full list doesn't
// dump it all on screen at once; search still reaches everything.
let mut font_names = cx.text_system().all_font_names();
if !font_names.contains(&family) {
font_names.push(family.clone());
font_names.sort_unstable();
}
let selected_font_index = font_names
.iter()
.position(|n| *n == family)
.map(|row| IndexPath::default().row(row));
let font_select = cx.new(|cx| {
SelectState::new(
SearchableVec::new(font_names.clone()),
selected_font_index,
window,
cx,
)
.searchable(true)
});
// Bold / italic pickers share the font list but prepend a "Default" row
// (the `FONT_DEFAULT_LABEL` sentinel) so the user can clear a distinct
// face back to synthesized emphasis.
let build_alt_font_select = |value: &Option<String>,
names: &[String],
window: &mut Window,
cx: &mut Context<Self>| {
let mut rows = Vec::with_capacity(names.len() + 1);
rows.push(crate::ui::settings::FONT_DEFAULT_LABEL.to_string());
rows.extend(names.iter().cloned());
let selected = value
.as_ref()
.and_then(|v| rows.iter().position(|n| n == v))
.unwrap_or(0);
cx.new(|cx| {
SelectState::new(
SearchableVec::new(rows),
Some(IndexPath::default().row(selected)),
window,
cx,
)
.searchable(true)
})
};
let font_bold_select = build_alt_font_select(&font_bold, &font_names, window, cx);
let font_italic_select = build_alt_font_select(&font_italic, &font_names, window, cx);
subs.push(cx.subscribe_in(
&font_select,
window,
|this, _select, ev: &SelectEvent<SearchableVec<String>>, _window, cx| {
if let SelectEvent::Confirm(Some(family)) = ev {
this.commit_font_family(family.clone(), cx);
}
},
));
subs.push(cx.subscribe_in(
&font_bold_select,
window,
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
if let SelectEvent::Confirm(Some(name)) = ev {
this.commit_font_family_emphasis(true, name.clone(), cx);
}
},
));
subs.push(cx.subscribe_in(
&font_italic_select,
window,
|this, _s, ev: &SelectEvent<SearchableVec<String>>, _w, cx| {
if let SelectEvent::Confirm(Some(name)) = ev {
this.commit_font_family_emphasis(false, name.clone(), cx);
}
},
));
(font_select, font_bold_select, font_italic_select)
}
/// Shell program/args and working-directory inputs, committing on Enter/blur.
fn build_shell_inputs(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> (Entity<InputState>, Entity<InputState>, Entity<InputState>) {
let cfg = cx.global::<Config>();
// Pre-fill the shell inputs from config; an unset `shell` leaves them
// empty so the placeholders advertise the platform default.
let (shell_program, shell_args) = match &cfg.shell {
Some(s) => (s.program.clone(), s.args.join(" ")),
None => (String::new(), String::new()),
};
let wd_path = cfg.working_directory.path.clone();
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
"login shell"
};
let shell_program_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(platform_default)
.default_value(shell_program)
});
let shell_args_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("none")
.default_value(shell_args)
});
let wd_path_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("/path/to/directory")
.default_value(wd_path)
});
let commit_shell = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_shell(cx);
}
};
let commit_wd = |this: &mut Self, ev: &InputEvent, cx: &mut Context<Self>| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_working_directory_path(cx);
}
};
subs.push(
cx.subscribe_in(&shell_program_input, window, move |this, _i, ev, _w, cx| {
commit_shell(this, ev, cx)
}),
);
subs.push(
cx.subscribe_in(&shell_args_input, window, move |this, _i, ev, _w, cx| {
commit_shell(this, ev, cx)
}),
);
subs.push(
cx.subscribe_in(&wd_path_input, window, move |this, _i, ev, _w, cx| {
commit_wd(this, ev, cx)
}),
);
(shell_program_input, shell_args_input, wd_path_input)
}
/// Window-opacity slider for the Appearance page (20%100%). Emits `Change`
/// continuously as the user drags; each tick sets the global override and
/// repaints, so the translucency is live under the thumb.
fn build_window_opacity_slider(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SliderState> {
let eff = Self::effective_window_opacity(cx);
let slider = cx.new(|_| {
SliderState::new()
.min(0.2)
.max(1.0)
.step(0.01)
.default_value(eff)
});
subs.push(
cx.subscribe_in(&slider, window, |this, _s, ev: &SliderEvent, window, cx| {
if let SliderEvent::Change(v) = ev {
this.set_window_opacity(v.start(), window, cx);
}
}),
);
slider
}
/// Mouse-scroll multiplier slider (0.5×–5×). Emits `Change` continuously as
/// the user drags; each writes + persists the multiplier.
fn build_scroll_slider(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SliderState> {
let scroll_mult = cx.global::<Config>().mouse_scroll_multiplier;
let scroll_slider = cx.new(|_| {
SliderState::new()
.min(0.5)
.max(5.0)
.step(0.25)
.default_value(scroll_mult)
});
subs.push(cx.subscribe_in(
&scroll_slider,
window,
|this, _s, ev: &SliderEvent, _w, cx| {
if let SliderEvent::Change(v) = ev {
this.set_mouse_scroll_multiplier(v.start(), cx);
}
},
));
scroll_slider
}
/// Close the settings overlay (Esc inside the panel, or Cmd+, again),
/// dropping its widget state and returning focus to the active terminal.
pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings.take().is_some() {
self.focus_active(window, cx);
cx.notify();
}
}
/// Open Settings focused on `section`, opening the overlay if it's closed.
/// Unlike `toggle_settings`, this never closes an already-open Settings — the
/// entry points that jump to a specific section (e.g. SSH profiles) use it.
pub(crate) fn open_settings_section(
&mut self,
section: SettingsSection,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.settings.is_none() {
self.toggle_settings(window, cx);
}
self.select_settings_section(section, cx);
}
/// Open Settings → SSH with `id`'s profile loaded into the inline edit form
/// (the ⌘⏎ / Edit affordance on a saved profile).
pub(crate) fn open_ssh_profile_in_settings(
&mut self,
id: uuid::Uuid,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings_section(SettingsSection::Ssh, window, cx);
if let Some(profile) = cx
.global::<Config>()
.ssh_profiles
.iter()
.find(|p| p.id == id)
.cloned()
{
self.ssh_form_load(&profile, window, cx);
}
}
/// Open Settings → SSH with a new profile seeded from a QuickConnect target
/// ("save as profile"), ready to edit and save.
pub(crate) fn open_ssh_profile_new_from_target(
&mut self,
target: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings_section(SettingsSection::Ssh, window, cx);
let mut profile = crate::core::ssh_profile::SshProfile::new(String::new());
if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) {
profile.port = qc.port_or_default();
profile.host = qc.host;
if let Some(user) = qc.user {
profile.user = user;
}
if profile.name.is_empty() {
profile.name = profile.host.clone();
}
}
self.ssh_form_load(&profile, window, cx);
}
/// Apply the picked font family live to every terminal and persist it.
fn commit_font_family(&mut self, family: String, cx: &mut Context<Self>) {
self.font_family = family.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let family = family.clone();
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
}
}
let cfg = cx.global_mut::<Config>();
cfg.font_family = family;
cfg.save();
cx.notify();
}
/// Apply a distinct bold or italic face (or clear it back to synthesized
/// emphasis when the `FONT_DEFAULT_LABEL` sentinel is picked) live to every
/// pane, and persist it. `bold == true` targets the bold face, else italic.
fn commit_font_family_emphasis(&mut self, bold: bool, name: String, cx: &mut Context<Self>) {
let family = (name != crate::ui::settings::FONT_DEFAULT_LABEL).then_some(name);
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let family = family.clone();
leaf.update(cx, |v, cx| {
if bold {
v.set_font_family_bold(family, cx);
} else {
v.set_font_family_italic(family, cx);
}
});
}
}
if bold {
self.font_family_bold = family.clone();
} else {
self.font_family_italic = family.clone();
}
let cfg = cx.global_mut::<Config>();
if bold {
cfg.font_family_bold = family;
} else {
cfg.font_family_italic = family;
}
cfg.save();
cx.notify();
}
/// Re-apply hot-reloaded config to every live pane. Wired to
/// `observe_global::<Config>`, so an external edit to `config.json` — picked
/// up by the watcher in `main.rs`, which swaps the `Config` global — flows to
/// the on-screen terminals without a restart. This complements `apply_theme`
/// (which already handles the color side) by covering the font knobs that
/// live on `Tty7App`/the panes: size, line height, and family.
///
/// Each field is diffed against the currently-applied value and skipped when
/// unchanged. That keeps this a no-op for the much more frequent case where
/// *our own* code mutated the global (every font setter and `set_preset`
/// writes it), and — because we never write the global or `save()` from here
/// — closes the save → watch → reload loop that would otherwise oscillate.
fn reload_from_config(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Re-apply the theme with the window in hand: the watcher task calls
// `apply_theme(None)` (colors/palette), but the window-bound effects — the
// Transparent↔Blurred background flip and traffic-light re-pinning — only
// happen here. Also keeps the Appearance opacity slider's thumb on a value
// that was hand-edited in `config.json` or the theme file.
apply_theme(Some(window), cx);
self.sync_window_opacity_slider(window, cx);
let config = cx.global::<Config>().clone();
if config.cursor_style != self.terminal_cursor_style
|| config.scrollback_limit != self.terminal_scrollback_limit
{
self.terminal_cursor_style = config.cursor_style;
self.terminal_scrollback_limit = config.scrollback_limit;
self.apply_terminal_config_to_panes(&config, cx);
}
let (font_size, line_height, font_family, font_features) = {
let cfg = cx.global::<Config>();
(
cfg.font_size,
cfg.line_height,
cfg.font_family.clone(),
cfg.font_features.clone(),
)
};
// Keep the runtime sidebar width in step with the config (an external
// edit to `config.json`, or our own drag-end persist which re-fires this).
self.sidebar_width.set(cx.global::<Config>().sidebar_width);
if font_size != self.font_size {
self.font_size = font_size;
let px_size = px(font_size);
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.font_size = px_size;
cx.notify();
});
}
}
}
if line_height != self.line_height {
self.line_height = line_height;
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
v.line_height_mul = line_height;
cx.notify();
});
}
}
}
if font_family != self.font_family {
self.font_family = font_family.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let family = font_family.clone();
leaf.update(cx, |v, cx| v.set_font_family(family, cx));
}
}
}
if font_features != self.font_features {
self.font_features = font_features.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let features = font_features.clone();
leaf.update(cx, |v, cx| v.set_font_features(features, cx));
}
}
}
let (bold, italic) = {
let cfg = cx.global::<Config>();
(cfg.font_family_bold.clone(), cfg.font_family_italic.clone())
};
if bold != self.font_family_bold {
self.font_family_bold = bold.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let bold = bold.clone();
leaf.update(cx, |v, cx| v.set_font_family_bold(bold, cx));
}
}
}
if italic != self.font_family_italic {
self.font_family_italic = italic.clone();
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
let italic = italic.clone();
leaf.update(cx, |v, cx| v.set_font_family_italic(italic, cx));
}
}
}
// Mouse-reporting is cached per view (the gates run without a `cx`), so a
// hot-reload must push it into every open pane. Diffed per leaf so an
// unrelated config edit doesn't churn panes that already agree.
let report_mouse = cx.global::<Config>().mouse_reporting;
for tab in &self.tabs {
for leaf in tab.pane.leaves() {
leaf.update(cx, |v, cx| {
if v.report_mouse != report_mouse {
v.report_mouse = report_mouse;
cx.notify();
}
});
}
}
cx.notify();
}
/// Persist the shell program + args from the settings inputs. An empty
/// program clears the override (`shell: None`), so the daemon falls back to
/// the platform default. Only newly spawned panes pick this up — the daemon
/// reads `config.json` fresh on each PTY spawn — so running shells are
/// untouched. There's nothing to apply live here; we just save.
fn commit_shell(&mut self, cx: &mut Context<Self>) {
let Some(settings) = self.active_settings() else {
return;
};
let program = settings
.shell_program_input
.read(cx)
.value()
.trim()
.to_string();
let args: Vec<String> = settings
.shell_args_input
.read(cx)
.value()
.split_whitespace()
.map(str::to_string)
.collect();
let shell = if program.is_empty() {
None
} else {
Some(ShellConfig { program, args })
};
let cfg = cx.global_mut::<Config>();
if cfg.shell == shell {
return; // no change — avoid a redundant disk write on every Blur
}
cfg.shell = shell;
cfg.save();
cx.notify();
}
/// Change the working-directory strategy. Only affects newly spawned panes
/// (the daemon reads `config.json` fresh per spawn), like the shell setting.
pub(crate) fn set_working_directory_strategy(
&mut self,
strategy: crate::core::config::WdStrategy,
cx: &mut Context<Self>,
) {
let cfg = cx.global_mut::<Config>();
if cfg.working_directory.strategy == strategy {
return;
}
cfg.working_directory.strategy = strategy;
cfg.save();
cx.notify();
}
/// Persist the custom working-directory path from the settings input. Only
/// used when the strategy is `Custom`, but stored regardless so switching back
/// restores it.
fn commit_working_directory_path(&mut self, cx: &mut Context<Self>) {
let Some(path) = self
.active_settings()
.map(|s| s.wd_path_input.read(cx).value().trim().to_string())
else {
return;
};
let cfg = cx.global_mut::<Config>();
if cfg.working_directory.path == path {
return;
}
cfg.working_directory.path = path;
cfg.save();
cx.notify();
}
/// The active tab's settings state, if it is the settings tab.
/// The open settings page's state, if the overlay is showing. The single
/// accessor every settings widget/handler reads, so the rest of the settings
/// code is agnostic to where the state lives.
pub(crate) fn active_settings(&self) -> Option<&SettingsState> {
self.settings.as_ref()
}
pub(crate) fn active_settings_mut(&mut self) -> Option<&mut SettingsState> {
self.settings.as_mut()
}
/// The status-dot colour for a tab whose representative pane is an SSH
/// session (PRD FR-E2), as an RGB value from the same hardcoded semantic
/// palette as [`AgentStatus::dot_rgb`] — not the theme's UI tokens, which
/// in this app are soft neutral fills (accent is the list-selection grey)
/// and read as no state at all. Native panes are phase-coloured
/// (connecting = amber, connected = green, failed/disconnected = red); a
/// foreground `ssh` typed into a shell gets a plain neutral dot. `None`
/// for non-SSH tabs (no dot).
///
/// [`AgentStatus::dot_rgb`]: crate::core::cli_agent::AgentStatus::dot_rgb
pub(crate) fn tab_ssh_dot(&self, tab: &Tab, cx: &App) -> Option<u32> {
use crate::daemon::protocol::SshPhase;
let leaf = tab.pane.first_leaf()?;
let v = leaf.read(cx);
if let Some(phase) = v.ssh_phase() {
// Native pane.
let rgb = if v.ssh_disconnected() {
0xEF4444 // red: link lost
} else {
match phase {
SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B, // amber: in flight
SshPhase::Connected => 0x22C55E, // green: link up
SshPhase::Failed { .. } => 0xEF4444, // red: never made it
}
};
Some(rgb)
} else if v.remote_context().is_some() {
// A foreground `ssh` typed into a shell: a plain neutral dot.
Some(0x9CA3AF)
} else {
None
}
}
/// Whether `leaf` is a live, connected native-SSH pane whose effective
/// warn-on-close is on (per-profile override, else the global toggle).
fn leaf_is_warn_ssh(&self, leaf: &Entity<TerminalView>, cx: &App) -> bool {
use crate::daemon::protocol::SshPhase;
let v = leaf.read(cx);
let connected = matches!(v.ssh_phase(), Some(SshPhase::Connected)) && !v.terminal.exited;
if !connected {
return false;
}
let cfg = cx.global::<Config>();
let per_profile = v
.ssh_spec()
.and_then(|s| s.profile_id.clone())
.and_then(|id| uuid::Uuid::parse_str(&id).ok())
.and_then(|id| cfg.ssh_profiles.iter().find(|p| p.id == id))
.and_then(|p| p.warn_on_close);
per_profile.unwrap_or(cfg.ssh_warn_on_close)
}
/// Whether the tab at `index` holds any live warn-on-close SSH pane (FR-E3).
pub(crate) fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool {
self.tabs
.get(index)
.map(|t| t.pane.leaves().iter().any(|l| self.leaf_is_warn_ssh(l, cx)))
.unwrap_or(false)
}
/// Whether the focused pane is a live warn-on-close SSH pane (FR-E3).
pub(crate) fn focused_pane_is_warn_ssh(&self, window: &Window, cx: &App) -> bool {
self.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.map(|l| self.leaf_is_warn_ssh(&l, cx))
.unwrap_or(false)
}
/// Proceed with a pending SSH-close after confirmation (FR-E3).
pub(crate) fn confirm_ssh_close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match self.ssh_close_confirm {
Some(SshCloseKind::Tab(i)) => self.close_tab(i, window, cx),
Some(SshCloseKind::Pane) => self.close_pane(window, cx),
None => {}
}
}
/// Dismiss the SSH-close confirmation, leaving the session open (FR-E3).
pub(crate) fn cancel_ssh_close(&mut self, cx: &mut Context<Self>) {
self.ssh_close_confirm = None;
cx.notify();
}
pub(crate) fn active_ssh_pane(
&self,
window: &Window,
cx: &App,
) -> Option<(u64, RemoteContext)> {
let pane = self
.tabs
.get(self.active)?
.pane
.focused_or_first(window, cx)?;
let pane = pane.read(cx);
Some((pane.pane_id, pane.remote_context()?))
}
/// The focused pane when it is a *connected native* SSH session — the gate for
/// the pane's tunnel / SFTP action buttons (top-right of the terminal body).
/// `None` for a foreground `ssh`, a still-connecting native pane, or a non-SSH
/// pane, so those never grow the action buttons.
pub(crate) fn active_connected_native_ssh_pane(
&self,
window: &Window,
cx: &App,
) -> Option<(u64, RemoteContext)> {
use crate::daemon::protocol::{RemoteKind, SshPhase};
let (pane_id, remote) = self.active_ssh_pane(window, cx)?;
if remote.kind != RemoteKind::NativeSsh {
return None;
}
let leaf = self
.tabs
.get(self.active)?
.pane
.focused_or_first(window, cx)?;
matches!(leaf.read(cx).ssh_phase(), Some(SshPhase::Connected)).then_some((pane_id, remote))
}
/// Select a sidebar section in the settings page (no-op when it's closed).
pub(crate) fn select_settings_section(
&mut self,
target: SettingsSection,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
s.section = target;
// Leaving the Keybindings page abandons any in-progress capture, so
// the interceptor doesn't keep swallowing keys off-screen.
s.recording = None;
// Entering Agents re-reads the hook install states, so edits made
// behind the panel's back (another tty7, a hand edit) show up.
if target == SettingsSection::Agents {
s.agent_hooks_states = Self::agent_hooks_snapshot();
}
}
cx.notify();
}
/// Every hook-capable agent paired with its current on-disk install state,
/// in the order the Agents section lists them.
fn agent_hooks_snapshot() -> Vec<(
crate::core::agent_hooks::HookAgent,
crate::core::agent_hooks::HooksState,
)> {
crate::core::agent_hooks::HookAgent::ALL
.into_iter()
.map(|agent| (agent, crate::core::agent_hooks::hooks_state(agent)))
.collect()
}
/// Settings → Agents: install (or rewrite in place) one agent's hooks,
/// then fold the outcome back into the panel — status row + note line.
pub(crate) fn settings_install_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
let result = crate::core::agent_hooks::install_hooks(agent);
self.finish_agent_hooks_action(agent, result, cx);
}
/// Settings → Agents: remove one agent's tty7 hooks (user hooks survive).
pub(crate) fn settings_uninstall_agent_hooks(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
cx: &mut Context<Self>,
) {
let result = crate::core::agent_hooks::uninstall_hooks(agent);
self.finish_agent_hooks_action(agent, result, cx);
}
/// Shared tail of the Agents-section hook actions: re-read the on-disk
/// states (the ground truth, whatever the action just did) and surface the
/// action's own summary or error as the note under that agent's row.
fn finish_agent_hooks_action(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
result: anyhow::Result<String>,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_states = Self::agent_hooks_snapshot();
s.agent_hooks_note = Some((
agent,
match result {
Ok(summary) => summary,
Err(e) => format!("Failed: {e}"),
},
));
}
cx.notify();
}
/// Keep the settings selection on a section that has search hits: if the
/// query changed and the current section no longer matches, jump to the
/// best-matching one so the shown page always reflects the search. A section
/// that still has matches is left alone, so the user's own click isn't yanked
/// away as they keep typing.
pub(crate) fn autoselect_settings_search(&mut self, cx: &mut Context<Self>) {
let Some(settings) = self.settings.as_ref() else {
return;
};
let query = settings.search.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return;
}
if crate::ui::settings::section_match_count(settings.section, &query) > 0 {
return;
}
if let Some(best) = crate::ui::settings::best_matching_section(&query) {
self.select_settings_section(best, cx);
}
}
// ----- Keybindings editing (Settings → Keybindings) --------------------
/// Begin capturing a new shortcut for `action`: install a keystroke
/// interceptor that swallows the next keypress and records it, and stash it
/// on the settings state so it stays active only while recording. Any prior
/// capture is replaced.
pub(crate) fn start_recording_key(
&mut self,
action: String,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// The interceptor fires app-wide *before* keymap dispatch, so a chord
// like ⌘T is captured here instead of opening a new tab. It runs until
// the returned `Subscription` is dropped (capture done / Esc / cancel).
let this = cx.weak_entity();
let intercept = cx.intercept_keystrokes(move |ev, _window, cx| {
let keystroke = ev.keystroke.clone();
let _ = this.update(cx, |this, cx| this.on_record_key(&keystroke, cx));
// Keep the key from also triggering an action / reaching a surface.
cx.stop_propagation();
});
self.record_gen = self.record_gen.wrapping_add(1);
if let Some(s) = self.active_settings_mut() {
s.rebinding_note = None;
s.recording = Some(Recording {
action,
chords: Vec::new(),
_intercept: intercept,
});
}
cx.notify();
}
/// Handle a keystroke captured during recording. Esc cancels. Backspace
/// removes the last captured chord, or — with nothing captured yet — resets
/// the action to its default. Any other key appends a chord and (re)starts
/// the pause-to-commit timer, so single chords and sequences (e.g. the tmux
/// preset's `ctrl-b x`) are recorded the same way.
fn on_record_key(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
let Some((action, has_chords)) = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.map(|r| (r.action.clone(), !r.chords.is_empty()))
else {
return;
};
match keystroke.key.as_str() {
"escape" => {
self.stop_recording(cx);
return;
}
"backspace" | "delete" => {
if has_chords {
// Edit the sequence: drop the last chord and keep capturing.
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.pop();
}
let still_has = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.is_some_and(|r| !r.chords.is_empty());
if still_has {
self.schedule_recording_commit(cx);
} else {
// Nothing left to commit; wait for a fresh keypress.
self.record_gen = self.record_gen.wrapping_add(1);
}
cx.notify();
} else {
self.stop_recording(cx);
self.reset_keybinding(action, cx);
}
return;
}
_ => {}
}
// A lone modifier press (⌘ held, no key yet) has nothing to bind — keep
// waiting for a real key.
let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else {
return;
};
if let Some(r) = self
.active_settings_mut()
.and_then(|s| s.recording.as_mut())
{
r.chords.push(spec);
}
self.schedule_recording_commit(cx);
cx.notify();
}
/// (Re)arm the pause-to-commit timer: after a short quiet window with no new
/// chord, the captured sequence is committed. Bumping `record_gen` first
/// invalidates any earlier timer, so only the latest keypress's timer fires.
fn schedule_recording_commit(&mut self, cx: &mut Context<Self>) {
self.record_gen = self.record_gen.wrapping_add(1);
let generation = self.record_gen;
cx.spawn(async move |this, cx| {
smol::Timer::after(std::time::Duration::from_millis(RECORD_COMMIT_DELAY_MS)).await;
let _ = this.update(cx, |this, cx| {
if this.record_gen == generation {
this.commit_recording(cx);
}
});
})
.detach();
}
/// Commit the captured chords (joined into a sequence spec) as the action's
/// override. A no-op if capture ended or nothing was captured.
fn commit_recording(&mut self, cx: &mut Context<Self>) {
let Some((action, chords)) = self
.active_settings()
.and_then(|s| s.recording.as_ref())
.filter(|r| !r.chords.is_empty())
.map(|r| (r.action.clone(), r.chords.clone()))
else {
return;
};
self.stop_recording(cx);
self.assign_keybinding(action, chords.join(" "), cx);
}
/// Drop the active capture (interceptor released, any pending commit timer
/// invalidated) without changing anything.
fn stop_recording(&mut self, cx: &mut Context<Self>) {
self.record_gen = self.record_gen.wrapping_add(1);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
}
cx.notify();
}
/// Assign `spec` to `action`. If another action already owns that keystroke,
/// unbind it (last-writer-wins would otherwise be order-dependent) and note
/// the takeover so the user can undo it with a reset.
fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) {
// Find the current owner of this exact keystroke, if it isn't `action`.
let displaced = crate::ui::keymap::effective_bindings(cx)
.into_iter()
.find(|(a, k)| *k == spec && *a != action)
.map(|(a, _)| a);
let note = displaced.as_ref().map(|other| {
format!(
"{} took the shortcut from {}, which is now unset.",
humanize_action(&action),
humanize_action(other)
)
});
self.update_config(cx, |cfg| {
if let Some(other) = &displaced {
// Explicit empty override = "unbound" (distinct from a reset,
// which would restore that action's default and re-conflict).
cfg.keybindings.insert(other.clone(), String::new());
}
cfg.keybindings.insert(action, spec);
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.rebinding_note = note;
}
cx.notify();
}
/// Reset one action to its built-in default (drop its override) and
/// re-install the keymap.
pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| {
cfg.keybindings.remove(&action);
});
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
/// Clear every keybinding override, restoring the full default table.
pub(crate) fn restore_default_keybindings(&mut self, cx: &mut Context<Self>) {
self.update_config(cx, |cfg| cfg.keybindings.clear());
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
/// Switch the keybinding preset ("default" / "tmux") and re-install the
/// keymap so the change is live immediately.
pub(crate) fn set_keybinding_preset(&mut self, preset: &str, cx: &mut Context<Self>) {
let preset = preset.to_string();
self.update_config(cx, |cfg| cfg.keybinding_preset = preset);
crate::ui::keymap::rebind(cx);
if let Some(s) = self.active_settings_mut() {
s.recording = None;
s.rebinding_note = None;
}
cx.notify();
}
/// Set the tmux preset's prefix chord (e.g. `ctrl-b` / `ctrl-a`) and
/// re-install the keymap.
pub(crate) fn set_keybinding_prefix(&mut self, prefix: &str, cx: &mut Context<Self>) {
let prefix = prefix.to_string();
self.update_config(cx, |cfg| cfg.prefix = prefix);
crate::ui::keymap::rebind(cx);
cx.notify();
}
/// Open `config.json` with the OS default handler (Settings → Keybindings).
/// A fresh install may never have saved yet, so write the current config
/// first — the button must not point at a missing file.
// The "Open config file" button was temporarily pulled from the UI; keep the
// handler around so re-enabling it is a one-line change in `settings.rs`.
#[allow(dead_code)]
pub(crate) fn open_config_file(&self, cx: &Context<Self>) {
let Some(path) = crate::core::config::config_path("config.json") else {
return;
};
if !path.exists() {
cx.global::<Config>().save();
}
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());
}
}
/// Open the GitHub Releases page in the browser — the "Download" action of
/// the Settings → About update prompt. Deliberately hand-off, not
/// self-update: the newest build is one click away on the web. Delegates to
/// `core::update` so the settings button and the update modal share it.
pub(crate) fn open_releases_page(&self) {
crate::core::update::open_releases_page();
}
}
impl Render for Tty7App {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip
// drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the
// zero-tab home page keeps the full-width horizontal layout, so an empty
// rail never appears.
let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left)
&& !self.tabs.is_empty();
let strip = self.tab_strip(!vertical, window, cx);
let sidebar = vertical.then(|| self.tab_sidebar(window, cx));
// Gate the pane action buttons (tunnel / SFTP) + their panels to a
// connected native-SSH pane; a foreground `ssh` or a still-connecting
// session shows only the top-left status strip, no action buttons.
let active_ssh_pane = self.active_connected_native_ssh_pane(window, cx);
// The Cmd+F find bar pins to the same top-right slot as these action
// buttons and, being deep inside the pane tree, paints *under* them
// (gpui stacks by child order, and this overlay is a later sibling of
// `body`). Give the find bar that slot: while it's open on the focused
// pane, suppress the tunnel/SFTP icons so they don't bleed through.
let search_open = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.map(|leaf| leaf.read(cx).search.is_some())
.unwrap_or(false);
// Native-SSH status strip / reconnect notice for the focused pane (E1/E4).
let ssh_status = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
.and_then(|leaf| self.render_ssh_status_strip(&leaf, cx));
// Render the active tab's pane tree; show focus rings only when split.
let body = match self.tabs.get(self.active) {
// Zero tabs: the window's own face — the home page (see `ui::home`).
None => self.render_home(cx).into_any_element(),
Some(active_tab) => {
// If a pane is maximized and it belongs to the active tab, render
// just that leaf full-window; otherwise the normal split layout.
let maximized = self.maximized.as_ref().filter(|leaf| {
active_tab
.pane
.leaves()
.iter()
.any(|l| l.entity_id() == leaf.entity_id())
});
match maximized {
Some(leaf) => div()
.size_full()
.overflow_hidden()
.child(leaf.clone())
.into_any_element(),
None => {
let show_focus = active_tab.pane.leaves().len() > 1;
active_tab.pane.render(show_focus, window, cx)
}
}
}
};
// The title strip (a transparent unified title bar carrying `strip`) and
// the terminal body area — shared by both layouts.
let title_bar = TitleBar::new()
// Taller than the stock 34px bar so the tabs read substantial and
// roomy instead of cramped. `.h(..)` lands in the component's
// `refine_style`, applied after its own `.h(TITLE_BAR_HEIGHT)`, so
// this override wins.
.h(px(TITLE_BAR_HEIGHT))
.bg(cx.theme().transparent)
.border_color(cx.theme().transparent)
.child(strip);
let body_area = div()
.flex_1()
.relative()
.overflow_hidden()
.child(body)
// Pane-contextual tunnel / SFTP action buttons, pinned top-right of
// the terminal area when the active pane is a connected native SSH
// session (the tunnel button also drives the forwards panel).
.when_some(active_ssh_pane, |this, (pane_id, remote)| {
// Hide the top-right tunnel/SFTP icons while the find bar owns
// that slot; the bottom-docked SFTP panel is unaffected.
this.when(!search_open, |this| {
this.child(self.render_loopback_forward_overlay(pane_id, &remote, cx))
})
// Pane-contextual SFTP panel (WS5), docked right when open for
// this (native-SSH) pane.
.when_some(
self.render_sftp_overlay(pane_id, &remote, window, cx),
|this, panel| this.child(panel),
)
})
// In-pane native-SSH auth / host-key sheet (WS3), shown over the pane
// that raised the prompt.
.when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| {
this.child(el)
})
// Native-SSH status strip / reconnect notice (E1/E4).
.when_some(ssh_status, |this, el| this.child(el))
// Live-SSH close-confirmation sheet (E3).
.when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| {
this.child(el)
})
// "New Worktree Tab" confirmation sheet (from the tab context menu).
.when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
this.child(el)
})
// Working-tree diff overlay (clicked from a sidebar git line) —
// last child, so it paints over every pane-contextual element
// above. It covers only the body: the sidebar stays interactive.
.when_some(self.render_diff_overlay(cx), |this, el| this.child(el));
// The two layouts. Horizontal (default): a column of [title bar / body].
// Vertical: the rail is a full-height *left column* that reaches the very
// top of the window — the traffic lights sit on its surface — with the
// title strip and terminal stacked in the right column. That way the rail
// surface has no seam with the title bar and reads as one continuous
// panel.
let main_layout = match sidebar {
Some(sidebar) => div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_row()
.child(sidebar)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(title_bar)
.child(body_area),
)
.into_any_element(),
None => div()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
.child(title_bar)
.child(body_area)
.into_any_element(),
};
// The real window background paint: gradient-aware and opacity-carrying
// (see `theme::window_background`), plus the theme's optional background
// image. Falls back to the component theme's solid before the first
// `apply_theme` has published the global.
let (window_bg, bg_image) = match cx.try_global::<crate::ui::presets::ActiveBackground>() {
Some(bg) => (window_background(bg), bg.image.clone()),
None => (cx.theme().background.into(), None),
};
// Settings is a full-window overlay (not a tab): it covers the tab rail,
// title strip, and terminal so it never crowds the tab list. `occlude`
// blocks input to the elements behind it. It fills the window edge to
// edge — its own nav sidebar reserves the title-bar zone internally (so
// that rail reaches the top like the tab rail), rather than insetting the
// whole page here.
let settings_overlay = self.settings.is_some().then(|| {
div()
.absolute()
.inset_0()
.occlude()
// Same gradient-aware paint as the root, so a gradient theme's
// settings page doesn't snap to a flat color. A translucent
// theme's alpha rides along, letting the background image show
// through here too. This second layer compounds the alpha over
// the root's paint — deliberate: the overlay must occlude the
// terminal behind it to stay readable.
.bg(window_bg)
.child(self.render_settings(cx))
});
div()
.id("tty7-root")
.size_full()
.flex()
.flex_col()
.bg(window_bg)
.text_color(cx.theme().foreground)
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
.on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx)))
.on_action(
cx.listener(|this, _: &CloseActiveTab, window, cx| this.close_pane(window, cx)),
)
.on_action(cx.listener(|this, _: &SplitRight, window, cx| {
this.split(Axis::Horizontal, window, cx)
}))
.on_action(
cx.listener(|this, _: &SplitDown, window, cx| {
this.split(Axis::Vertical, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &FocusNextPane, window, cx| {
this.cycle_pane(true, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &FocusPrevPane, window, cx| {
this.cycle_pane(false, window, cx)
}),
)
.on_action(cx.listener(|this, _: &FocusPaneLeft, window, cx| {
this.focus_pane_dir(Dir::Left, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneRight, window, cx| {
this.focus_pane_dir(Dir::Right, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneUp, window, cx| {
this.focus_pane_dir(Dir::Up, window, cx)
}))
.on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| {
this.focus_pane_dir(Dir::Down, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneLeft, window, cx| {
this.resize_pane(Dir::Left, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneRight, window, cx| {
this.resize_pane(Dir::Right, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneUp, window, cx| {
this.resize_pane(Dir::Up, window, cx)
}))
.on_action(cx.listener(|this, _: &ResizePaneDown, window, cx| {
this.resize_pane(Dir::Down, window, cx)
}))
.on_action(
cx.listener(|this, _: &SwapPaneNext, window, cx| this.swap_pane(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &SwapPanePrev, window, cx| this.swap_pane(false, window, cx)),
)
.on_action(
cx.listener(|this, _: &NextTab, window, cx| this.cycle_tab(true, window, cx)),
)
.on_action(
cx.listener(|this, _: &PrevTab, window, cx| this.cycle_tab(false, window, cx)),
)
.on_action(
cx.listener(|this, _: &ActivateTab1, window, cx| {
this.activate_visual(0, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab2, window, cx| {
this.activate_visual(1, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab3, window, cx| {
this.activate_visual(2, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab4, window, cx| {
this.activate_visual(3, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab5, window, cx| {
this.activate_visual(4, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab6, window, cx| {
this.activate_visual(5, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab7, window, cx| {
this.activate_visual(6, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab8, window, cx| {
this.activate_visual(7, window, cx)
}),
)
.on_action(
cx.listener(|this, _: &ActivateTab9, window, cx| {
this.activate_visual(8, window, cx)
}),
)
.on_action(cx.listener(|this, _: &IncreaseFontSize, _window, cx| {
this.change_font_size(FONT_SIZE_STEP, cx)
}))
.on_action(cx.listener(|this, _: &DecreaseFontSize, _window, cx| {
this.change_font_size(-FONT_SIZE_STEP, cx)
}))
.on_action(cx.listener(|this, _: &ResetFontSize, _window, cx| this.reset_font_size(cx)))
.on_action(
cx.listener(|this, _: &TogglePalette, window, cx| this.toggle_palette(window, cx)),
)
.on_action(cx.listener(|this, _: &ReopenClosedTab, window, cx| {
this.reopen_closed_tab(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleMaximizePane, window, cx| {
this.toggle_maximize(window, cx)
}))
.on_action(
cx.listener(|_, _: &ToggleFullscreen, window, _cx| window.toggle_fullscreen()),
)
.on_action(
cx.listener(|this, _: &ToggleTabSidebar, _window, cx| this.toggle_tab_sidebar(cx)),
)
.on_action(
cx.listener(|this, _: &OpenSettings, window, cx| this.toggle_settings(window, cx)),
)
.on_action(
cx.listener(|this, _: &RestartDaemon, window, cx| this.restart_daemon(window, cx)),
)
.on_action(cx.listener(|this, _: &ToggleSftp, window, cx| this.toggle_sftp(window, cx)))
// Quit lives on the same element-tree action path as every other Cmd
// shortcut above, so a focused terminal routes `cmd-q` here rather
// than relying solely on the global handler (which the keystroke
// doesn't reach while focus is deep in the terminal view).
.on_action(cx.listener(|_, _: &Quit, _, cx| cx.quit()))
.on_action(cx.listener(|this, _: &OpenSshProfiles, window, cx| {
this.open_settings_section(SettingsSection::Ssh, window, cx)
}))
.on_action(cx.listener(|this, _: &RestartSshSession, window, cx| {
this.restart_ssh_session(window, cx)
}))
// The theme's background image, composited over the background fill
// at its own opacity and under all content. Absolute, so it doesn't
// participate in the flex column; the wrapper clips the Cover
// overflow (gpui's `img` paints the fitted bounds unclipped).
.when_some(bg_image, |this, image| {
this.child(
div()
.absolute()
.inset_0()
.overflow_hidden()
.opacity(image.opacity)
.child(
img(image.path)
.size_full()
.object_fit(gpui::ObjectFit::Cover),
),
)
})
.child(main_layout)
// Settings overlay, above the tabs/terminal when open.
.when_some(settings_overlay, |this, overlay| this.child(overlay))
// Command palette overlay, layered above everything when open.
.when_some(self.palette.clone(), |this, palette| this.child(palette))
// Toast layer for `window.push_notification` (worktree/SSH errors).
// gpui-component's Root only *stores* the list; the root view must
// render the layer — without this child every toast was invisible.
.children(gpui_component::Root::render_notification_layer(window, cx))
}
}
/// Convert a live `Tab` (pane tree + name) into its serializable mirror.
fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
SessionTab {
name: tab.name.clone(),
pane: pane_to_session(&tab.pane, cx),
sidebar_group: tab.sidebar_group.borrow().clone(),
}
}
/// Convert a live `Pane` tree into its serializable mirror, reading each
/// leaf's current cwd and each split's axis + ratio. Used when saving.
fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
match pane {
Pane::Leaf(view) => {
let view = view.read(cx);
SessionPane::Leaf {
// Local cwd only. A restored pane whose daemon pane is gone
// respawns on the *default local shell* (a shell pick isn't
// persisted), so a remote cwd would come back paired with a
// local shell that cannot chdir into it. Native-SSH panes
// reconnect from `ssh_spec` and the daemon discards the cwd
// for them anyway (`server::SpawnNativeSsh`).
cwd: view.local_cwd(),
pane_id: Some(view.pane_id),
// Persist the secret-free native-SSH spec so a *dead* pane can be
// reconnected on restore (FR-E4/C2); `None` for local panes. A
// live pane reattaches by `pane_id` and never needs this.
ssh_spec: view.ssh_spec(),
// The running agent + its native session id (when its hooks
// reported one), so a pane the daemon loses can resume the
// agent conversation instead of just reopening a shell.
agent: view.agent(),
agent_session_id: view.agent_session().and_then(|s| s.session_id),
}
}
Pane::Split {
axis, a, b, ratio, ..
} => SessionPane::Split {
axis: match axis {
Axis::Horizontal => SessionAxis::Horizontal,
Axis::Vertical => SessionAxis::Vertical,
},
ratio: ratio.get(),
a: Box::new(pane_to_session(a, cx)),
b: Box::new(pane_to_session(b, cx)),
},
// A transient `Empty` should never be persisted; mirror it as a bare
// leaf so restore still yields a usable terminal.
Pane::Empty => SessionPane::Leaf {
cwd: None,
pane_id: None,
ssh_spec: None,
agent: None,
agent_session_id: None,
},
}
}
/// Set of daemon pane ids currently alive, used by `session_to_pane` to decide
/// per leaf whether to re-`attach` or `spawn`. Computed once per restore from the
/// daemon's `List`; empty (→ all-fresh) when the daemon is unreachable.
fn alive_panes() -> std::collections::HashSet<u64> {
crate::terminal::RemoteTerminal::list_panes()
.into_iter()
.filter(|p| p.alive)
.map(|p| p.pane_id)
.collect()
}
/// Rebuild the tab list from a persisted `Session`, re-attaching to still-live
/// daemon panes where possible and spawning fresh shells otherwise. An absent or
/// empty session yields no tabs (the home page). Shared by first-launch restore
/// (`Tty7App::new`) and the daemon-restart rebuild (`restart_daemon`), so the two
/// stay in lockstep.
fn tabs_from_session(
session: Option<Session>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> (Vec<Tab>, usize) {
let Some(session) = session.filter(|s| !s.tabs.is_empty()) else {
return (Vec::new(), 0);
};
// Ask the daemon once which panes are still alive, so leaves re-attach to
// surviving shells instead of all spawning fresh.
let alive = alive_panes();
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
for st in &session.tabs {
let pane = session_to_pane(&st.pane, &alive, font_size, window, cx);
tabs.push(Tab {
pane,
name: st.name.clone(),
last_focused: None,
diff_overlay: None,
// Seed the sticky group from the saved session so the sidebar
// renders grouped on the first frame; the first landed probe
// corrects it if the tab's repo changed while we were gone.
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
});
}
// Clamp the saved active index into the rebuilt range.
let active = session.active.min(tabs.len() - 1);
(tabs, active)
}
/// Rebuild a live `Pane` tree from a saved `SessionPane`. A leaf whose saved
/// `pane_id` is still alive in the daemon re-`attach`es (process + scrollback
/// intact); otherwise it spawns a fresh shell in the saved cwd. `alive` is the
/// daemon's current pane set, computed once by the caller.
fn session_to_pane(
sp: &SessionPane,
alive: &std::collections::HashSet<u64>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Pane {
match sp {
SessionPane::Leaf {
cwd,
pane_id,
ssh_spec,
agent,
agent_session_id,
} => {
// Only restore the pane id when the daemon confirms it's still live;
// a stale id (daemon restarted, pane killed) falls back to a spawn.
let restore = (*pane_id).filter(|id| alive.contains(id));
// A *dead* native-SSH leaf (spec persisted, pane no longer alive)
// reconnects rather than dropping back to a local shell (FR-C2/E4):
// re-resolve secrets from the profile when it names one, else reuse
// the secret-free spec and let the auth sheets prompt.
if restore.is_none() {
if let Some(spec) = ssh_spec.clone() {
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
Ok(view) => return Pane::leaf(view),
// Keep restore alive: fall through to a local shell in
// this slot rather than aborting startup.
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
}
}
}
// A shell pick isn't persisted in the session, so a stale pane that
// must respawn comes back on the default shell.
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
// A pane that could NOT re-attach lost its running agent with the
// daemon; when we captured that agent's native session id, hand
// the fresh shell its resume command so the conversation picks up
// where it left off (cmux's auto-resume, config-gated). The bytes
// sit in the PTY input queue until the shell reads its first
// command — same mechanism as tmux send-keys at spawn.
if restore.is_none()
&& cx.global::<Config>().restore_agent_sessions
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
&& let Some(cmd) = agent.resume_command(id)
{
view.read(cx).run_command_line(&cmd);
}
Pane::leaf(view)
}
SessionPane::Split { axis, ratio, a, b } => {
let axis = match axis {
SessionAxis::Horizontal => Axis::Horizontal,
SessionAxis::Vertical => Axis::Vertical,
};
let a = session_to_pane(a, alive, font_size, window, cx);
let b = session_to_pane(b, alive, font_size, window, cx);
Pane::split_node(axis, *ratio, a, b)
}
}
}
fn new_terminal(
font_size: f32,
working_directory: Option<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Entity<TerminalView> {
let view = cx.new(|cx| {
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
.expect("failed to start terminal");
// Inherit the current global font size so new panes match existing ones.
view.font_size = px(font_size);
view
});
// A pane whose shell exits on its own (`exit`, Ctrl-D, a crash) closes
// itself, like every other terminal. This is the single place all panes
// are built — new tab, split, session restore — so the subscription
// covers them all; restore even cleans up panes that died while no
// client was attached (the daemon replays their exit on reattach).
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
app.on_child_exited(view.clone(), window, cx);
})
.detach();
// Native-SSH auth/host-key prompts raised by this pane → in-pane sheet. Same
// single build site as ChildExited, so every pane (new tab, split, restore)
// is covered.
cx.subscribe_in(
&view,
window,
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
app.on_auth_prompt_ready(view.clone(), window, cx);
},
)
.detach();
view
}
/// Build a native (russh) SSH terminal view for `spec`, wiring the same
/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`]
/// so it participates in auto-close and the in-pane auth sheets. Mirrors
/// `new_terminal` but takes the resolved connect spec instead of a shell.
/// Errors (daemon down/stale, spawn refused) are returned, never panicked —
/// callers surface them and keep the app alive.
pub(crate) fn new_terminal_native(
font_size: f32,
working_directory: Option<std::path::PathBuf>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> anyhow::Result<Entity<TerminalView>> {
let parts = TerminalView::spawn_native_ssh_terminal(spec, working_directory)?;
let view = cx.new(|cx| {
let mut view = TerminalView::from_native_ssh_parts(parts, window, cx);
view.font_size = px(font_size);
view
});
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
app.on_child_exited(view.clone(), window, cx);
})
.detach();
cx.subscribe_in(
&view,
window,
|app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| {
app.on_auth_prompt_ready(view.clone(), window, cx);
},
)
.detach();
Ok(view)
}
pub(crate) fn parse_ssh_option_words(input: &str) -> Result<Vec<String>, ()> {
let mut words = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut chars = input.chars();
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
(Some(_), '\\') => {
if let Some(next) = chars.next() {
current.push(next);
}
}
(Some(_), c) => current.push(c),
(None, '\'' | '"') => quote = Some(ch),
(None, c) if c.is_whitespace() => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
(None, c) => current.push(c),
}
}
if quote.is_some() {
return Err(());
}
if !current.is_empty() {
words.push(current);
}
Ok(words)
}
/// The data a typed "SSH: Add Connection…" line resolves to: a transient profile
/// plus the raw `ProxyJump` target (from `-J`), ready for
/// [`crate::ui::ssh_connect::native_spec_from_transient_profile`].
pub(crate) struct ParsedSshConnect {
pub profile: crate::core::ssh_profile::SshProfile,
pub proxy_jump: Option<String>,
}
/// Parse a typed connect line (`[ssh] [flags] user@host[:port]`) into native
/// connect data (PRD §3.1). Only the trivially-mappable flags are honored — `-p`,
/// `-l`, `-i` (repeatable), `-J`, and `-o User=`/`-o Port=`/`-o ProxyJump=`; other
/// options are ignored (best-effort). A remote command, a `--` separator, an
/// unbalanced quote, or a missing/invalid host is an `Err(reason)` surfaced as an
/// inline notice — never a silent shell-out. Returns the user-facing reason string.
pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, String> {
use crate::core::ssh_profile::{SshProfile, parse_quick_connect};
let mut words = parse_ssh_option_words(input)
.map_err(|_| "Unbalanced quotes in the SSH command".to_string())?;
if words.first().is_some_and(|word| word == "ssh") {
words.remove(0);
}
let mut target: Option<String> = None;
let mut user: Option<String> = None;
let mut port: Option<u16> = None;
let mut identities: Vec<String> = Vec::new();
let mut jump: Option<String> = None;
let mut i = 0;
while i < words.len() {
let word = words[i].clone();
if word == "--" {
return Err("Remote commands aren't supported here".to_string());
}
if let Some((flag, attached)) = ssh_short_flag(&word) {
// Consume the value (attached `-p2222` form or the next word) when the
// flag takes one.
let value = if ssh_option_takes_value(flag) {
if !attached.is_empty() {
attached
} else {
i += 1;
match words.get(i) {
Some(v) => v.clone(),
None => return Err(format!("-{flag} needs a value")),
}
}
} else {
String::new()
};
match flag {
'p' => {
port = Some(
value
.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("Invalid port \u{201c}{value}\u{201d}"))?,
)
}
'l' => user = Some(value),
'i' => identities.push(value),
'J' => jump = Some(value),
'o' => apply_ssh_o_option(&value, &mut user, &mut port, &mut jump)?,
// Any other flag (value already consumed if it took one) is ignored.
_ => {}
}
} else if word.starts_with('-') {
// A long option (`--foo`) or bare `-`: not something we map.
return Err(format!("Unsupported option \u{201c}{word}\u{201d}"));
} else if target.is_none() {
target = Some(word);
} else {
return Err("Remote commands aren't supported here".to_string());
}
i += 1;
}
let target = target.ok_or_else(|| "Enter a host to connect to".to_string())?;
let qc = parse_quick_connect(&target)
.ok_or_else(|| format!("Can't parse host \u{201c}{target}\u{201d}"))?;
let mut profile = SshProfile::new(qc.host.clone());
profile.host = qc.host;
// Explicit `-p` / `-o Port=` wins over a `:port` on the target, else default 22.
profile.port = port.or(qc.port).unwrap_or(22);
// Explicit `-l` / `-o User=` wins over `user@` on the target.
if let Some(user) = user.or(qc.user) {
profile.user = user;
}
profile.identity_files = identities;
Ok(ParsedSshConnect {
profile,
proxy_jump: jump,
})
}
/// Split a short-option word into `(flag, attached_value)` — `-p2222` → `('p',
/// "2222")`, `-J` → `('J', "")`. `None` for a non-option, `--`/long option, or a
/// bare `-`.
fn ssh_short_flag(word: &str) -> Option<(char, String)> {
let rest = word.strip_prefix('-')?;
if rest.is_empty() || rest.starts_with('-') {
return None;
}
let mut chars = rest.chars();
let flag = chars.next()?;
Some((flag, chars.as_str().to_string()))
}
/// Apply the trivially-mappable `-o Name=Value` options (`User`/`Port`/
/// `ProxyJump`); anything else is ignored (best-effort).
fn apply_ssh_o_option(
value: &str,
user: &mut Option<String>,
port: &mut Option<u16>,
jump: &mut Option<String>,
) -> Result<(), String> {
let Some((name, val)) = value.split_once('=') else {
return Ok(());
};
match name.to_ascii_lowercase().as_str() {
"user" => *user = Some(val.to_string()),
"port" => {
*port = Some(
val.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("Invalid port \u{201c}{val}\u{201d}"))?,
)
}
"proxyjump" => *jump = Some(val.to_string()),
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{parse_ssh_connect_input, parse_ssh_option_words};
#[test]
fn parses_ssh_option_words_with_quotes() {
assert_eq!(
parse_ssh_option_words("-p 2222 -J 'jump host' -o \"User=dev\"").unwrap(),
vec!["-p", "2222", "-J", "jump host", "-o", "User=dev"]
);
}
#[test]
fn rejects_unclosed_ssh_option_quote() {
assert!(parse_ssh_option_words("-J 'jump").is_err());
}
#[test]
fn parses_typed_connect_into_native_profile() {
// Bare `user@host:port` (optional `ssh` prefix) → transient profile.
let p = parse_ssh_connect_input("ssh deploy@10.0.0.5:2222").unwrap();
assert_eq!(p.profile.host, "10.0.0.5");
assert_eq!(p.profile.user, "deploy");
assert_eq!(p.profile.port, 2222);
assert!(p.proxy_jump.is_none());
}
#[test]
fn parses_typed_connect_flags_and_jump() {
// Options before and after the target; `-p`/`-l`/`-i`/`-J` all map.
let p =
parse_ssh_connect_input("ssh -p 2222 -l dev -i ~/.ssh/id_ed25519 -J 'jump host' host")
.unwrap();
assert_eq!(p.profile.host, "host");
assert_eq!(p.profile.user, "dev");
assert_eq!(p.profile.port, 2222);
assert_eq!(
p.profile.identity_files,
vec!["~/.ssh/id_ed25519".to_string()]
);
assert_eq!(p.proxy_jump.as_deref(), Some("jump host"));
// Attached short-flag form (`-p2222`) and `-o User=`/`-o Port=`.
let p = parse_ssh_connect_input("host -p2222 -o User=deploy -o Port=2200").unwrap();
assert_eq!(p.profile.user, "deploy");
// `-o Port=` wins over an earlier `-p` (last write wins in the -o pass).
assert_eq!(p.profile.port, 2200);
}
#[test]
fn explicit_flags_override_target_userhost() {
// `-l` / `-p` override the `user@host:port` on the target.
let p = parse_ssh_connect_input("ssh me@host:22 -l other -p 2200").unwrap();
assert_eq!(p.profile.user, "other");
assert_eq!(p.profile.port, 2200);
}
#[test]
fn rejects_bad_typed_connect_lines() {
// No host at all.
assert!(parse_ssh_connect_input("ssh -p 2222").is_err());
// A remote command or `--` separator is not a connect line.
assert!(parse_ssh_connect_input("ssh dev uptime").is_err());
assert!(parse_ssh_connect_input("ssh -- dev").is_err());
// Unbalanced quote.
assert!(parse_ssh_connect_input("ssh 'host").is_err());
// Invalid port.
assert!(parse_ssh_connect_input("ssh host -p 0").is_err());
}
}
#[cfg(test)]
mod keybinding_gpui_tests {
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::settings::SettingsSection;
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) {
// The pause-to-commit is a real `smol::Timer` (off the deterministic
// executor), so waiting on it parks the test thread.
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
});
// Wrap the app in a `gpui_component::Root` exactly like `main.rs` does:
// the settings overlay's search box (and other gpui-component widgets)
// reach for `Root` on the window, which panics if the window's first
// layer isn't one. `Root::view()` hands the typed app entity back so the
// tests still drive `Tty7App` directly.
let window = cx.add_window(|window, cx| {
let app = cx.new(|cx| Tty7App::with_session(Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
window
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.background_executor.run_until_parked();
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
let vcx = VisualTestContext::from_window(window.into(), cx);
(app, vcx)
}
/// Open Settings → Keybindings and begin capturing `action`.
fn begin_capture(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, action: &str) {
let action = action.to_string();
app.update_in(vcx, |app, window, cx| {
app.toggle_settings(window, cx);
app.select_settings_section(SettingsSection::Keybindings, cx);
app.start_recording_key(action, window, cx);
});
}
/// Poll (bounded) until `action` has the expected override in config — the
/// commit fires on a real ~650ms timer.
fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
vcx.background_executor.run_until_parked();
let got = vcx.update(|_, cx| cx.global::<Config>().keybindings.get(action).cloned());
if got.as_deref() == Some(expected) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"binding for {action} never became {expected:?} (last {got:?})"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
// End-to-end: open Settings → Keybindings, capture a shortcut for New Tab,
// and confirm the recorded keystroke is normalized, persisted to config, and
// the capture ends. This drives the real interceptor path installed by
// `start_recording_key`, not just the pure helpers.
#[gpui::test]
fn recording_a_shortcut_writes_the_override_and_ends_capture(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
begin_capture(&app, &mut vcx, "NewTab");
// The platform-primary modifier normalizes to `secondary` on write.
vcx.simulate_keystrokes("secondary-shift-n");
wait_for_binding(&mut vcx, "NewTab", "secondary-shift-n");
let recording = app.update_in(&mut vcx, |app, _, _| {
app.active_settings().map(|s| s.recording.is_some())
});
assert_eq!(
recording,
Some(false),
"capture should end after committing"
);
}
// A two-chord sequence (the tmux-style `ctrl-b x`) records as one binding.
#[gpui::test]
fn recording_a_two_chord_sequence_writes_the_full_spec(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
begin_capture(&app, &mut vcx, "CloseActiveTab");
// Two chords in quick succession, then the pause commits the sequence.
// `secondary-b` is used (not a bare `ctrl-b`) so the recorded spec is
// identical on macOS and elsewhere — the primary modifier normalizes to
// `secondary` either way.
vcx.simulate_keystrokes("secondary-b");
vcx.simulate_keystrokes("x");
wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x");
}
// Esc during capture cancels without touching config.
#[gpui::test]
fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) {
let (app, mut vcx) = harness(cx);
app.update_in(&mut vcx, |app, window, cx| {
app.toggle_settings(window, cx);
app.select_settings_section(SettingsSection::Keybindings, cx);
app.start_recording_key("NewTab".to_string(), window, cx);
});
vcx.simulate_keystrokes("escape");
vcx.background_executor.run_until_parked();
let stored = vcx.update(|_, cx| cx.global::<Config>().keybindings.contains_key("NewTab"));
assert!(!stored, "Esc must not persist a binding");
let recording = app.update_in(&mut vcx, |app, _, _| {
app.active_settings().map(|s| s.recording.is_some())
});
assert_eq!(recording, Some(false));
}
}