feat(agents): install agent hooks onto the machine that runs the agent

Hook installation was written for one machine — this one — and a remote
workspace runs its agents on the far side of the connection, where none of
that is true: a different `$HOME`, a different filesystem separator, and a
`tty7` binary at a path this client published rather than the one it is
running from.

`HookTarget` is that machine, borrowed for the length of one background
task: `local` resolves our own home and binary, `remote` takes the home a
handshake reported and the `tty7-server-<version>` this client installed
there. Every path the installer builds now goes through it, via `Host::join`
rather than `PathBuf::join` — a Windows client installing onto a Linux box
was writing `/home/me\.claude`. The three things that are only true locally
(our own environment variables, atomic writes, running the `codex` CLI) are
gated on `is_local` instead of assumed. Settings grows a machine picker so
the page states which one it is acting on.

Also in this commit, three unrelated UI fixes:

- The settings sidebar's search placeholder sat 6px right of every nav
  label under it — a `small` (14px) magnifier where the rows use 16, a 4px
  gap where they use 8, and an `Input` that adds `input_px` (12px at the
  default size) whether or not it draws a box. All three corrected, so the
  placeholder starts on the rows' 32px text column.

- The "'X' is still running — reopen it from the workspace menu" toast is
  gone, along with the `workspace_detach_hint_seen` flag that existed only
  to show it once. Detaching a workspace is what ⌘W has always done here
  and the Window menu already lists what came off screen; a one-time
  lecture on top of that is noise. Old configs carrying the key still load
  — `Config` doesn't deny unknown fields.
This commit is contained in:
l0ng-ai
2026-07-28 17:08:21 +08:00
parent 8239b298a9
commit 5f77c40f96
6 changed files with 1100 additions and 371 deletions
File diff suppressed because it is too large Load Diff
-8
View File
@@ -307,13 +307,6 @@ pub struct Config {
/// second, so toggling it (Settings or a `config.json` edit) applies live.
#[serde(default = "default_true")]
pub show_tray_icon: bool,
/// Whether the user has already been told, once, that closing a window puts
/// its workspace away rather than ending it. ⌘W is muscle memory and the
/// result is off-screen, so the first time it happens deserves one line
/// pointing at the title bar's workspace menu — and never again. Set to
/// `true` by that hint; there is no UI to reset it (nor a reason to).
#[serde(default)]
pub workspace_detach_hint_seen: bool,
/// Ask before closing the *last* window (the close that also quits the app).
/// On by default, which is the behavior every build so far has had.
///
@@ -759,7 +752,6 @@ impl Default for Config {
notify_threshold_secs: default_notify_threshold_secs(),
restore_session: true,
show_tray_icon: true,
workspace_detach_hint_seen: false,
confirm_window_close: true,
// Visual flash preserves the pre-config behavior (the bell always
// flashed); opting into None/Audible is a deliberate change.
+250 -48
View File
@@ -1263,27 +1263,15 @@ impl Tty7App {
// An empty workspace has nothing to come back to, so it is dropped
// outright instead of accumulating as a blank row in the picker —
// every `New Workspace` the user closes without using would leave one.
let name = if self.tabs.is_empty() {
if self.tabs.is_empty() {
WorkspaceStore::remove(cx, self.workspace);
String::new()
} else {
WorkspaceStore::close_window(cx, self.workspace);
// Read after the update so the name reflects what was just stored.
WorkspaceStore::all(cx)
.get(self.workspace)
.map(|w| w.display_name())
.unwrap_or_default()
};
}
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
// The workspace just moved from "on screen" to "detached" — the Window
// menu is the only place that says so.
crate::ui::windows::refresh_menu(cx);
// ...and the first time that happens, say it out loud once. Only for a
// workspace with something in it: putting away an empty window teaches
// nothing (and it was dropped outright above).
if !name.is_empty() {
crate::ui::windows::hint_detached(cx, &name);
}
}
/// Design §15's other half: a workspace's forwards belong to the workspace,
@@ -4366,7 +4354,9 @@ impl Tty7App {
rebinding_note: None,
ssh_form: None,
ssh_detail: crate::ui::settings::SshDetail::None,
agent_hooks_states: Self::agent_hooks_snapshot(),
agent_hooks_host: crate::ui::host_ops::HostId::LOCAL,
agent_hooks_states: crate::ui::settings::AgentHooksView::Loading,
agent_hooks_seq: 0,
agent_hooks_note: None,
_subs: subs,
});
@@ -4383,6 +4373,7 @@ impl Tty7App {
}
// Build the color editor if we opened straight onto an editable theme.
self.rebuild_theme_editor(window, cx);
self.ensure_agent_hooks_loaded(cx);
cx.notify();
}
@@ -5125,33 +5116,206 @@ impl Tty7App {
// 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();
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
}
}
self.ensure_agent_hooks_loaded(cx);
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()
/// Read the Agents page's rows, but only when that is the page on screen.
///
/// Gated on the section because the read is a config file per agent — on a
/// remote machine, a round trip per agent — and opening Settings on
/// Appearance has no business paying for six of those.
fn ensure_agent_hooks_loaded(&mut self, cx: &mut Context<Self>) {
if self
.active_settings()
.is_some_and(|s| s.section == SettingsSection::Agents)
{
self.load_agent_hooks_states(cx);
}
}
/// Settings Agents: install (or rewrite in place) one agent's hooks,
/// then fold the outcome back into the panel — status row + note line.
/// The machines the Agents section offers: this computer, then every remote
/// machine this process is connected to right now.
///
/// Only connected ones, because a hook install *is* a write to that
/// machine's disk — there is nothing to offer without a link. The ones that
/// are configured but offline are named under the picker instead of being
/// silently dropped from it.
pub(crate) fn agent_hooks_machines(
&self,
cx: &mut App,
) -> Vec<crate::ui::settings::AgentHooksMachine> {
use crate::ui::settings::AgentHooksMachine;
let mut out = vec![AgentHooksMachine {
host: crate::ui::host_ops::HostId::LOCAL,
label: "This Computer".to_string(),
}];
// The label is the name the user gave the box; `HostId` alone is a
// hash. `available_hosts` is the same lookup the workspace switcher
// does for exactly this reason.
let configured = crate::ui::remote_connect::available_hosts(cx);
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
if id.is_local() {
continue;
}
let label = configured
.iter()
.find(|h| h.target.host_id() == id)
.map(|h| h.label.clone())
.unwrap_or_else(|| "Remote machine".to_string());
out.push(AgentHooksMachine { host: id, label });
}
out
}
/// How many machines the Agents picker cannot offer because nothing is
/// connected to them.
///
/// Saved SSH profiles only — not the `~/.ssh/config` aliases
/// [`available_hosts`](crate::ui::remote_connect::available_hosts) also
/// returns. A config with fifty `Host` blocks is normal and most of them are
/// git transports that could never host a workspace; counting those would
/// turn a helpful footnote into "50 machines aren't connected".
pub(crate) fn agent_hooks_offline_count(&self, cx: &mut App) -> usize {
let connected = crate::ui::host_registry::HostRegistry::ids(cx);
cx.global::<Config>()
.ssh_profiles
.iter()
.filter(|p| {
!connected
.contains(&crate::core::session::RemoteTarget::Profile { id: p.id }.host_id())
})
.count()
}
/// Point the Agents section at another machine and read its state.
pub(crate) fn select_agent_hooks_host(
&mut self,
host: crate::ui::host_ops::HostId,
cx: &mut Context<Self>,
) {
if let Some(s) = self.settings.as_mut() {
if s.agent_hooks_host == host {
return;
}
s.agent_hooks_host = host;
// The note belonged to the machine we just left.
s.agent_hooks_note = None;
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading;
}
self.load_agent_hooks_states(cx);
cx.notify();
}
/// Read every hook-capable agent's install state off the selected machine,
/// in the background, and land the rows when they arrive.
///
/// Background because a `Host` call blocks and on a remote machine that is a
/// round trip *per agent* — six of them, on a link that may be an ocean
/// wide. Doing it inline is the window freeze this codebase has already
/// fixed twice.
fn load_agent_hooks_states(&mut self, cx: &mut Context<Self>) {
use crate::core::agent_hooks::{HookAgent, HookTarget};
use crate::ui::settings::{AgentHookRow, AgentHooksView};
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
return;
};
let seq = match self.settings.as_mut() {
Some(s) => {
s.agent_hooks_seq += 1;
s.agent_hooks_seq
}
None => return,
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_states =
AgentHooksView::Unavailable(Self::AGENT_HOOKS_OFFLINE.into());
}
cx.notify();
return;
};
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
let target = match &home {
Some(home) => HookTarget::remote(h, home.clone()),
None => HookTarget::local(h)?,
};
Some(
HookAgent::ALL
.into_iter()
.map(|agent| AgentHookRow {
agent,
state: crate::core::agent_hooks::hooks_state(&target, agent),
target: agent.target_display(&target),
})
.collect::<Vec<_>>(),
)
},
move |this, rows, cx| {
if let Some(s) = this.settings.as_mut()
&& s.agent_hooks_seq == seq
{
s.agent_hooks_states = match rows {
Some(rows) => AgentHooksView::Ready(rows),
None => AgentHooksView::Unavailable(
"tty7 could not work out this computer's home directory, so there is \
nowhere to install to."
.into(),
),
};
cx.notify();
}
},
);
}
/// What the Agents section says when the machine it is pointed at has no
/// live connection. One string, because the picker's footnote and the
/// rows' resting state have to agree.
const AGENT_HOOKS_OFFLINE: &'static str = concat!(
"Not connected to this machine, so its agent config can't be read or ",
"written. Open a workspace on it and come back."
);
/// The host object and remote home for the machine the Agents section is
/// pointed at, or `None` when it is a remote that is no longer connected.
///
/// `None` for the home means "this computer" — the local target reads its
/// own environment, which is the one place `$CLAUDE_CONFIG_DIR` and
/// `$XDG_CONFIG_HOME` are ours to honor.
fn agent_hooks_link(
&self,
host_id: crate::ui::host_ops::HostId,
cx: &mut App,
) -> Option<(crate::ui::host_ops::SharedHost, Option<std::path::PathBuf>)> {
let host = crate::ui::host_registry::HostRegistry::get(cx, host_id)?;
if host_id.is_local() {
return Some((host, None));
}
if !host.is_connected() {
return None;
}
let home = crate::ui::remote_connect::RemoteConnections::home(cx, host_id)?;
Some((host, Some(home)))
}
/// Settings → Agents: install (or rewrite in place) one agent's hooks on the
/// selected machine, 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);
self.run_agent_hooks_action(agent, true, cx);
}
/// Settings → Agents: remove one agent's tty7 hooks (user hooks survive).
@@ -5160,30 +5324,68 @@ impl Tty7App {
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);
self.run_agent_hooks_action(agent, false, 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(
/// Install or uninstall one agent's hooks on the selected machine, then
/// re-read that machine's states the ground truth, whatever the action
/// just did — and surface the action's own summary or error as the note
/// under its row.
///
/// Writing is a `Host` call too, so it takes the same background trip as the
/// read: an install into `~/.claude/settings.json` on a remote box is a read
/// and a write over the control connection.
fn run_agent_hooks_action(
&mut self,
agent: crate::core::agent_hooks::HookAgent,
result: anyhow::Result<String>,
install: bool,
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();
use crate::core::agent_hooks::HookTarget;
let Some(host_id) = self.settings.as_ref().map(|s| s.agent_hooks_host) else {
return;
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_note = Some((agent, Self::AGENT_HOOKS_OFFLINE.to_string()));
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Unavailable(
Self::AGENT_HOOKS_OFFLINE.into(),
);
}
cx.notify();
return;
};
crate::ui::host_ops::HostOps::run(
host,
cx,
move |h| {
let target = match &home {
Some(home) => HookTarget::remote(h, home.clone()),
None => HookTarget::local(h)
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?,
};
if install {
crate::core::agent_hooks::install_hooks(&target, agent)
} else {
crate::core::agent_hooks::uninstall_hooks(&target, agent)
}
},
move |this, result, cx| {
if let Some(s) = this.settings.as_mut() {
s.agent_hooks_note = Some((
agent,
match result {
Ok(summary) => summary,
Err(e) => format!("Failed: {e}"),
},
));
}
this.load_agent_hooks_states(cx);
cx.notify();
},
);
}
/// Keep the settings selection on a section that has search hits: if the
+28
View File
@@ -345,9 +345,37 @@ pub fn connect_blocking(
let rows = list_workspaces(&host)
.map_err(|e| format!("connected to {label}, but its workspace list failed: {e}"))?;
let home = host.home();
refresh_agent_hooks_once(&host, &home);
Ok(Connected { host, home, rows })
}
/// Machines whose agent hooks this process has already looked at.
static HOOKS_REFRESHED: Mutex<Vec<HostId>> = Mutex::new(Vec::new());
/// Heal this machine's stale tty7 agent hooks — the ones pointing at a
/// `tty7-server-<version>` an upgrade replaced (see
/// [`crate::core::agent_hooks::refresh_remote_hooks`]).
///
/// Off the connect's own thread, and once per machine per run: it is a config
/// read per agent over the control connection, and a reconnect — which happens
/// on a backoff loop — must not wait on six round trips to a box that may be an
/// ocean away. The hooks are for panes that do not exist yet at this point in
/// the connect, so nothing is racing it.
fn refresh_agent_hooks_once(host: &Arc<RemoteHost>, home: &std::path::Path) {
let id = host.id();
match HOOKS_REFRESHED.lock() {
Ok(mut seen) if !seen.contains(&id) => seen.push(id),
_ => return,
}
let (host, home) = (Arc::clone(host), home.to_path_buf());
std::thread::spawn(move || {
let refreshed = crate::core::agent_hooks::refresh_remote_hooks(&*host, home);
if refreshed > 0 {
log::info!("refreshed {refreshed} stale agent hook integration(s) on {id:?}");
}
});
}
/// The control handshake over the routed stream. Split out only because the
/// transport type differs per platform (a Unix socket here, a token-checked
/// loopback socket on Windows) and both need their shutdown wired so dropping
+255 -86
View File
@@ -39,6 +39,7 @@ use crate::ui::app::{
FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH_LINE, TILE_SIZE, TITLE_BAR_HEIGHT, ThemeEdit,
Tty7App,
};
use crate::ui::host_ops::HostId;
use crate::ui::presets;
/// Which section of the settings panel is currently selected in the sidebar.
@@ -512,21 +513,61 @@ pub(crate) struct SettingsState {
/// edit form); `None` shows the empty state (the "pick a profile" hint plus
/// the two global security toggles).
pub(crate) ssh_detail: SshDetail,
/// Install state of each agent's hook integration (Agents section), in
/// [`crate::core::agent_hooks::HookAgent::ALL`] order. Cached — captured
/// when the panel opens, re-read when the section is selected, and updated
/// after each install/uninstall — so rendering never touches the agents'
/// config files.
pub(crate) agent_hooks_states: Vec<(
crate::core::agent_hooks::HookAgent,
crate::core::agent_hooks::HooksState,
)>,
/// Which machine the Agents section is showing and acting on.
/// [`HostId::LOCAL`] until the user picks one of the connected remotes.
pub(crate) agent_hooks_host: HostId,
/// Install state of each agent's hook integration on
/// [`Self::agent_hooks_host`]. Cached — captured when the panel opens,
/// re-read when the section or the machine is selected, and updated after
/// each install/uninstall — because reading it is a file read per agent,
/// and on a remote machine that is a round trip per agent.
pub(crate) agent_hooks_states: AgentHooksView,
/// Discriminates the load whose answer is allowed to land. Switching
/// machines while a read is in flight would otherwise let the old
/// machine's rows arrive under the new machine's name.
pub(crate) agent_hooks_seq: u64,
/// Outcome of the last Agents-section hook action (install summary or
/// error), shown under that agent's row. Replaced by the next action.
pub(crate) agent_hooks_note: Option<(crate::core::agent_hooks::HookAgent, String)>,
pub(crate) _subs: Vec<Subscription>,
}
/// What Settings → Agents has to show for the machine it is pointed at.
///
/// Three states rather than a `Vec` that is empty when it doesn't know: reading
/// a remote machine's install state is a round trip per agent, so "still asking"
/// and "asked, nothing installed" are genuinely different answers and rendering
/// them the same is how a page silently lies for a second.
#[derive(Clone)]
pub(crate) enum AgentHooksView {
/// The read is in flight.
Loading,
/// One row per hook-capable agent, in
/// [`crate::core::agent_hooks::HookAgent::ALL`] order.
Ready(Vec<AgentHookRow>),
/// The machine can't be acted on, and the sentence says which hop gave up
/// (design §17: a failure is a resting state, not a blank).
Unavailable(String),
}
/// One agent's row, as read off a particular machine.
#[derive(Clone)]
pub(crate) struct AgentHookRow {
pub(crate) agent: crate::core::agent_hooks::HookAgent,
pub(crate) state: crate::core::agent_hooks::HooksState,
/// The file the integration lives in *on that machine*, `~`-abbreviated.
/// Resolved in the background with the rest of the read — it depends on the
/// machine's own home directory and separator, which render cannot ask for.
pub(crate) target: String,
}
/// One entry in the Agents section's machine picker.
#[derive(Clone)]
pub(crate) struct AgentHooksMachine {
pub(crate) host: HostId,
pub(crate) label: String,
}
/// The theme choice a picker card / the picker panel targets. `Manual` is the
/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are
/// the two follow-system slots (`Config::theme_preset_light` / `_dark`).
@@ -916,21 +957,32 @@ impl Tty7App {
.child(
h_flex()
.items_center()
.gap_1()
// Laid out to land on the nav rows below it rather than
// on the header's own inset: a `SidebarMenuItem` is
// `p_2` + a 16px icon + `gap_x_2`, so its label starts
// 32px into the rail. Matching that takes all three of
// these — the magnifier at the rows' 16px (not `small`,
// which is 14 and left the glyph reading a size below
// the column it heads), the same 8px gap after it, and
// `pl_0` on the input, which otherwise adds `input_px`
// (12px at the default size) whether or not it draws a
// box. Without them the placeholder sat 6px right of
// every label under it.
.gap_2()
// Stock magnifier, not tty7's: this page's glyphs run at
// 16px, where the detail panel's redraw reads thin and
// its handle stubby. See `assets::STOCK_PREFIX`.
.child(
Icon::empty()
.path("stock/icons/search.svg")
.small()
.size(px(16.))
.text_color(header_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.child(Input::new(&search).appearance(false)),
.child(Input::new(&search).appearance(false).pl_0()),
),
),
)
@@ -3337,17 +3389,27 @@ impl Tty7App {
.into_any_element()
}
/// Agents section: one row per hook-capable agent — install state + actions
/// per row, copy kept terse.
/// Agents section: a machine picker, then one row per hook-capable agent on
/// that machine — install state + actions per row, copy kept terse.
///
/// The picker is first because everything under it is *about* the chosen
/// machine: the paths, the states, and what Install writes. An agent running
/// in a remote workspace's pane runs on the remote box and reads that box's
/// `~/.claude/settings.json`, so installing here and expecting status there
/// was the whole gap this page closes.
fn render_settings_agents(&self, cx: &mut Context<Self>) -> AnyElement {
use crate::core::agent_hooks::HooksState;
let theme = cx.theme();
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
let (success, warning) = (theme.success, theme.warning);
let (states, note) = match self.active_settings() {
Some(s) => (s.agent_hooks_states.clone(), s.agent_hooks_note.clone()),
None => (Vec::new(), None),
let (view, note, selected_host) = match self.active_settings() {
Some(s) => (
s.agent_hooks_states.clone(),
s.agent_hooks_note.clone(),
s.agent_hooks_host,
),
None => (AgentHooksView::Loading, None, HostId::LOCAL),
};
let mut page = v_flex().child(self.section_intro(
@@ -3356,84 +3418,191 @@ impl Tty7App {
(working / waiting / done) in the tab bar. Only active inside tty7.",
cx,
));
for (i, (agent, state)) in states.into_iter().enumerate() {
// Status: a colored dot + one word; the dot is the only color on
// the page, so state reads at a glance.
let (dot_color, status_text) = match state {
HooksState::NotInstalled => (muted_fg, "Not installed"),
HooksState::Installed => (success, "Installed"),
HooksState::Outdated => (warning, "Outdated"),
};
// The primary action reads as what it will *do* from this state.
let primary_label = match state {
HooksState::NotInstalled => "Install",
HooksState::Installed => "Reinstall",
HooksState::Outdated => "Update",
};
let row_note = note
.as_ref()
.filter(|(for_agent, _)| *for_agent == agent)
.map(|(_, text)| text.clone());
// items_end: the whole stack shares the row's right edge, so
// status, buttons, and note line up across every agent row.
let control = v_flex()
.gap_2()
.items_end()
.child(
h_flex()
.gap_2()
.items_center()
.child(div().size_2().rounded_full().bg(dot_color))
.child(div().text_sm().text_color(foreground).child(status_text)),
)
.child(
h_flex()
.gap_2()
.child(
Button::new(("agent-hooks-install", i))
.label(primary_label)
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_install_agent_hooks(agent, cx)
})),
)
.when(state != HooksState::NotInstalled, |row| {
row.child(
Button::new(("agent-hooks-uninstall", i))
.label("Uninstall")
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_uninstall_agent_hooks(agent, cx)
})),
)
}),
)
// Width-capped so a long note (error text) wraps instead of
// inflating the shrink-proof control column and crushing the
// label to zero width.
.when_some(row_note, |col, text| {
col.child(
page = page.children(self.agent_hooks_machine_picker(selected_host, cx));
match view {
// A spinner would be four agents' worth of motion for a read that is
// usually instant; the page just says what it is doing and keeps its
// shape, so nothing jumps when the rows arrive.
AgentHooksView::Loading => {
return page
.child(
div()
.max_w_80()
.text_xs()
.text_right()
.py_4()
.text_sm()
.text_color(muted_fg)
.child(text),
.child("Reading this machine's agent config…"),
)
})
.into_any_element();
.into_any_element();
}
// §17: a resting state that says which hop gave up and what to do
// next, rather than rows that would silently write nowhere.
AgentHooksView::Unavailable(reason) => {
return page
.child(div().py_4().text_sm().text_color(warning).child(reason))
.into_any_element();
}
AgentHooksView::Ready(rows) => {
for (i, row) in rows.into_iter().enumerate() {
let agent = row.agent;
// Status: a colored dot + one word; the dot is the only color
// on the page, so state reads at a glance.
let (dot_color, status_text) = match row.state {
HooksState::NotInstalled => (muted_fg, "Not installed"),
HooksState::Installed => (success, "Installed"),
HooksState::Outdated => (warning, "Outdated"),
};
// The primary action reads as what it will *do* from this
// state.
let primary_label = match row.state {
HooksState::NotInstalled => "Install",
HooksState::Installed => "Reinstall",
HooksState::Outdated => "Update",
};
let row_note = note
.as_ref()
.filter(|(for_agent, _)| *for_agent == agent)
.map(|(_, text)| text.clone());
page = page.child(self.settings_row(
agent.display_name(),
agent.target_display(),
control,
cx,
));
// items_end: the whole stack shares the row's right edge, so
// status, buttons, and note line up across every agent row.
let control = v_flex()
.gap_2()
.items_end()
.child(
h_flex()
.gap_2()
.items_center()
.child(div().size_2().rounded_full().bg(dot_color))
.child(div().text_sm().text_color(foreground).child(status_text)),
)
.child(
h_flex()
.gap_2()
.child(
Button::new(("agent-hooks-install", i))
.label(primary_label)
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_install_agent_hooks(agent, cx)
})),
)
.when(row.state != HooksState::NotInstalled, |r| {
r.child(
Button::new(("agent-hooks-uninstall", i))
.label("Uninstall")
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.settings_uninstall_agent_hooks(agent, cx)
})),
)
}),
)
// Width-capped so a long note (error text) wraps instead
// of inflating the shrink-proof control column and
// crushing the label to zero width.
.when_some(row_note, |col, text| {
col.child(
div()
.max_w_80()
.text_xs()
.text_right()
.text_color(muted_fg)
.child(text),
)
})
.into_any_element();
page = page.child(self.settings_row(
agent.display_name(),
row.target,
control,
cx,
));
}
}
}
page.into_any_element()
}
/// The Agents section's machine picker: this computer plus every connected
/// remote, one chosen at a time.
///
/// `None` when this computer is the only machine there is — a picker with a
/// single choice is a control that asks a question with one answer, and the
/// page below it already says where the files go.
///
/// Hand-rolled rather than [`Self::segmented`] because the options are
/// machines, not a fixed `&'static [&'static str]` — but it reads off the
/// same interaction ladder, so it is the same control the rest of the sheet
/// speaks.
fn agent_hooks_machine_picker(&self, selected: HostId, cx: &mut Context<Self>) -> Option<Div> {
let sf = cx.global::<presets::Surfaces>().window;
let border = cx.theme().border;
let muted_fg = cx.theme().muted_foreground;
let machines = self.agent_hooks_machines(cx);
let offline = self.agent_hooks_offline_count(cx);
if machines.len() < 2 && offline == 0 {
return None;
}
Some(
v_flex()
.gap_2()
.mb_4()
.child(
h_flex()
.flex_wrap()
.gap_1p5()
.children(machines.into_iter().map(|machine| {
let active = machine.host == selected;
let host = machine.host;
h_flex()
.id(("agent-hooks-machine", host.0 as usize))
.h(px(24.))
.px_2p5()
.items_center()
.rounded_lg()
.border_1()
.border_color(border)
.bg(rgb(sf.base))
.text_sm()
.cursor_pointer()
// Both channels, every time: the fill locates the
// selection, the label colour and weight say it is
// the one — and keep saying it on a translucent
// window, where the fill washes over the desktop.
.when(active, |s| {
s.bg(rgb(sf.selected))
.text_color(rgb(sf.text_selected))
.font_weight(FontWeight::MEDIUM)
})
.when(!active, |s| {
s.text_color(rgb(sf.text_resting))
.hover(|h| h.bg(rgb(sf.hover)))
})
.active(|s| s.bg(rgb(sf.pressed)))
.child(machine.label)
.on_click(cx.listener(move |this, _, _w, cx| {
this.select_agent_hooks_host(host, cx)
}))
})),
)
// A saved machine that isn't connected is absent from the row above,
// and an absence explains nothing. Say the count and the next move
// rather than listing fifty `~/.ssh/config` aliases, most of which
// are git transports that could never host a workspace anyway.
.when(offline > 0, |col| {
col.child(div().text_xs().text_color(muted_fg).child(format!(
"{offline} more saved machine{} not connected — open a workspace on one to \
install its hooks there.",
if offline == 1 { " is" } else { "s are" }
)))
}),
)
}
/// Window & Tabs section: the app window's lifecycle and tab placement.
fn render_settings_window_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let cfg = cx.global::<Config>();
+1 -30
View File
@@ -19,7 +19,7 @@ use gpui::{
AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Bounds, Global, Styled as _,
TitlebarOptions, WeakEntity, Window, WindowBounds, WindowOptions, point, px, size,
};
use gpui_component::{Root, TitleBar, WindowExt as _};
use gpui_component::{Root, TitleBar};
use crate::core::config::{Config, StartupMode};
use crate::core::session::{WorkspaceId, WorkspaceStore};
@@ -230,35 +230,6 @@ pub fn open_with(cx: &mut App, workspace: Option<WorkspaceId>, fresh: FreshStart
refresh_menu(cx);
}
/// Tell the user *once* that closing a window put its workspace away rather
/// than ending it, and where to find it again.
///
/// ⌘W is muscle memory and its result is off-screen, so the very first time it
/// detaches real work the user deserves a pointer — and never again after that.
/// Shown on whichever window survives; with none left (the app is quitting)
/// there is nowhere to put it and nothing to come back to yet, so it waits for
/// a later detach.
pub fn hint_detached(cx: &mut App, name: &str) {
if cx.global::<Config>().workspace_detach_hint_seen {
return;
}
let Some(target) = WindowRegistry::most_recent(cx) else {
return;
};
let Some(handle) = WindowRegistry::window_for(cx, target) else {
return;
};
cx.global_mut::<Config>().workspace_detach_hint_seen = true;
cx.global::<Config>().save();
// The title bar's workspace menu, not the macOS Window menu: Windows and
// Linux have no menu bar, and the corner chip lists workspaces everywhere.
let message =
format!("{name}” is still running — reopen it from the workspace menu in the title bar");
let _ = handle.update(cx, |_, window, cx| {
window.push_notification(message, cx);
});
}
/// Rebuild the menu bar so the Window menu reflects the current workspace set.
///
/// macOS menus are static snapshots — nothing re-reads them when they open —