mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user