Files
tty7/src/main.rs
T
l0ng-aiandl0ng-ai cd9577c590 feat(agents): recognize CLI coding agents + git branch in the sidebar (#85)
* feat(agents): recognize CLI coding agents + show git branch in the sidebar

Observe (never wrap) third-party coding agents running in a pane — Claude
Code, Codex, Gemini CLI, Aider, Amp, OpenCode and ~10 more — and enrich the
UI around them, plus front each sidebar row with its git branch and diff.

Detection & identity
- Command-based detection over the foreground argv (launcher basename, and
  interpreter-wrapped `node …/cli.js` / `npx …` forms), with user rules via
  `agent_commands` in config. Brand avatars on the tab chip and sidebar row.

Rich status channel
- A per-pane state machine (idle / working / waiting-for-you / done) driven
  by agent-reported events over an OSC 777 sentinel channel
  (`tty7://cli-agent`, versioned JSON), sniffed daemon-side and streamed to
  the client (DaemonMsg::AgentStatus).
- `tty7 agent-hook claude <event>` + a palette installer wire Claude Code's
  lifecycle hooks up; the hook writes the sentinel to the controlling tty
  (with an ancestor-tty fallback for detached hook processes).
- Avatar status dot: working (blue) / waiting (amber) / done (green); an
  unread finished turn gets a crisp outer ring that clears on focus.

Notifications, resume, context feed
- "Needs your permission…" the moment an agent blocks; "finished after Ns"
  per turn, honoring the notify policy (rich turns suppress the coarse exit).
- Session resume: restored panes re-launch their conversation
  (`claude --resume …`), gated by `restore_agent_sessions` (default on).
- Palette commands send the current selection or the repo `git diff` to the
  running agent as a ready-made prompt.

Sidebar git line
- New `terminal::git_status`: off-thread `git` probe (branch, or short sha
  when detached; `git diff --numstat HEAD` line counts) with
  GIT_OPTIONAL_LOCKS=0, refreshed on cwd change or command finish, dropped
  on a stale cwd via a generation tag.
- Each row is avatar + title + `⎇ branch  +N −M` (green/red), sized to
  content; the redundant cwd/"Working…" lines and the aggregate rollup are
  gone — the status dot and branch line carry it.

672 tests pass.

* fix(agents): repair CI and refresh the git line when an agent turn ends

- The live PTY detection test used `sh -c 'exec -a codex cat'`, but
  `exec -a` is a bashism dash (Ubuntu's /bin/sh) rejects — spawn bash.
- cargo fmt over cli_agent.rs / view.rs / app.rs.
- An agent session is one long foreground command, so the back-to-prompt
  edge never refreshed the sidebar's branch/diff line while the agent
  worked — exactly when the working tree changes. poll_agent_status now
  reports a turn ending (transition into Done) and the poll reprobes git
  on that edge too.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-15 14:35:24 +08:00

399 lines
19 KiB
Rust

// Hide the console window on Windows release builds; keep it in debug builds
// so println!/eprintln! output remains visible while developing.
#![cfg_attr(
all(target_os = "windows", not(debug_assertions)),
windows_subsystem = "windows"
)]
mod core;
mod daemon;
mod terminal;
mod ui;
use crate::core::config::Config;
use crate::ui::app::Tty7App;
use crate::ui::assets::Assets;
use crate::ui::keymap;
use gpui::*;
use gpui_component::{ActiveTheme as _, Root, TitleBar};
/// Register the bundled Hack monospace faces with gpui's text system so the
/// default `font_family` ("Hack") renders identically on every machine, with no
/// dependency on the user having the font installed (the app bundles its
/// own copy). The four faces cover regular / bold / italic / bold-italic.
fn register_bundled_fonts(cx: &mut App) {
use std::borrow::Cow;
let fonts = vec![
Cow::Borrowed(include_bytes!("../assets/fonts/hack/Hack-Regular.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/hack/Hack-Bold.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/hack/Hack-Italic.ttf").as_slice()),
Cow::Borrowed(include_bytes!("../assets/fonts/hack/Hack-BoldItalic.ttf").as_slice()),
];
if let Err(e) = cx.text_system().add_fonts(fonts) {
log::warn!("failed to register bundled Hack fonts: {e}");
}
}
/// Watch `config.json` and hot-reload the app when it changes on disk, so
/// hand-edits (or an external tool rewriting the file) take effect live — no
/// restart. We watch the config *directory*, not the file: editors and our own
/// [`Config::save`] replace `config.json` via a temp-file + rename (atomic
/// write), which severs any watch bound to the original inode. Watching the
/// parent and filtering to `config.json` events survives the swap.
///
/// The `notify` callback fires on a background OS thread, which can't touch GPUI
/// state. We bridge to the app (main) thread the same way the daemon reader does
/// (see `terminal::remote`): a `smol::channel` carries a bare "something changed"
/// ping, and a `cx.spawn` task on the foreground executor drains it and does the
/// reload with a real `&mut App`.
///
/// Scope note: this re-applies theme + colors live (via `apply_theme`, which
/// reads the freshly-loaded `Config` global). Font size / line height / font
/// family are cached in `Tty7App`'s fields and pushed into each `TerminalView`,
/// so a live change to *those* keys needs a hook in `ui::app` (owned elsewhere);
/// they still take effect for newly-opened tabs and on restart. Font *family*
/// changes need no font re-registration: `add_fonts` is only for bundled/custom
/// face files (we ship Hack, registered once at startup); any other family is a
/// system font gpui resolves by name at render time.
fn spawn_config_watcher(cx: &mut App) {
use notify::{RecursiveMode, Watcher};
// Resolve the file we care about and the directory we actually watch. If the
// config dir doesn't resolve (no override/env/$HOME) there's nothing to do.
let Some(config_file) = crate::core::config::config_path("config.json") else {
return;
};
let Some(dir) = crate::core::config::config_dir_path() else {
return;
};
// The dir may not exist yet on a first run; watching a missing path errors.
// Create it so the watch attaches (harmless — the daemon/save would too).
let _ = std::fs::create_dir_all(&dir);
// Coalesce a save's burst of events (truncate → write → rename can fire
// several times) into a single reload: on the first ping we wait out a short
// quiet period, drain anything queued, then reload once.
const DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
let (tx, rx) = smol::channel::unbounded::<()>();
let watched_file = config_file.clone();
let handler = move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
// React to events that touch our `config.json`, or a theme file dropped
// into the `themes/` subfolder — both feed the same registry reload below.
// Everything else in the dir (`session.json`, `history`, the daemon
// socket, and our own `.config.json.tmp.<pid>` / `*.yaml.tmp.<pid>` atomic
// scratch files, whose extensions aren't theme extensions) is ignored.
let hit = event
.paths
.iter()
.any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p));
if hit {
// try_send: a full channel just means a reload is already pending;
// one ping is enough to trigger the (idempotent) reload.
let _ = tx.try_send(());
}
};
let mut watcher = match notify::recommended_watcher(handler) {
Ok(w) => w,
Err(e) => {
log::warn!("config hot-reload disabled: failed to create watcher: {e}");
return;
}
};
// Recursive so the `themes/` subfolder is covered too (it may not exist yet;
// FSEvents picks up subdirs created later). The handler filters the noise.
if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) {
log::warn!(
"config hot-reload disabled: failed to watch {}: {e}",
dir.display()
);
return;
}
// The `RecommendedWatcher` owns the background watch thread; dropping it stops
// watching. It has to live for the whole app, so we intentionally leak it
// rather than thread a handle through app state (there's exactly one, for the
// process lifetime, so a one-off leak is the simplest correct choice).
Box::leak(Box::new(watcher));
cx.spawn(async move |cx| {
while rx.recv().await.is_ok() {
// Debounce: let the save settle, then swallow the rest of the burst so
// we reload exactly once.
cx.background_executor().timer(DEBOUNCE).await;
while rx.try_recv().is_ok() {}
cx.update(|cx| {
// `Config::load` clamps/validates and falls back to defaults on a
// parse error (a half-written file mid-edit), so a bad reload can
// never crash the renderer — worst case we momentarily show
// defaults until the next (valid) save re-triggers this.
cx.set_global(Config::load());
// Reload the theme registry too, so edits to (or new) theme files
// in the themes folder take effect on the same hot-reload path.
crate::ui::presets::load_registry(cx);
crate::ui::theme::apply_cursor_hide_mode(cx);
// Re-paint theme + colors from the new config. We have no window
// handle in this global task, but `apply_theme` accepts `None`:
// it still updates the `Theme`/palette globals (what actually
// repaints); the only thing it skips is re-pinning the macOS
// traffic lights, which self-corrects on the next resize/activate.
crate::ui::theme::apply_theme(None, cx);
// Schedule every window to redraw so the new palette shows at once.
cx.refresh_windows();
});
}
// Loop only ends if every `Sender` drops — but the sole sender lives in
// the leaked watcher's handler, so in practice this runs for the app's
// lifetime.
})
.detach();
// Note on feedback loops: our own `Config::save` (theme toggle, font zoom)
// rewrites `config.json` and will trip this watcher. That's benign — the
// reload reads back the same content we just wrote and re-applies it
// idempotently, so it can't oscillate; it's at worst one redundant repaint.
}
/// Whether `p` is a theme file living directly in the `themes/` subfolder — a
/// `*.yaml` / `*.yml` / `*.itermcolors` whose parent directory is named `themes`.
/// The parent check keeps a stray yaml elsewhere in the config dir from tripping
/// a theme reload, and the extension check excludes the `*.tmp.<pid>` scratch
/// files atomic writes leave behind mid-save.
fn is_theme_file(p: &std::path::Path) -> bool {
p.parent().and_then(|d| d.file_name()) == Some(std::ffi::OsStr::new("themes"))
&& p.extension().and_then(|e| e.to_str()).is_some_and(|e| {
e.eq_ignore_ascii_case("yaml")
|| e.eq_ignore_ascii_case("yml")
|| e.eq_ignore_ascii_case("itermcolors")
})
}
/// Parse `--config-dir <path>` (or `--config-dir=<path>`) from the CLI and pin
/// it as the process config directory before anything reads config. Lets a dev
/// build keep its state in a throwaway folder — see the `dev` cargo alias.
fn apply_config_dir_arg() {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(path) = arg.strip_prefix("--config-dir=") {
crate::core::config::set_config_dir(path.into());
return;
}
if arg == "--config-dir" {
if let Some(path) = args.next() {
crate::core::config::set_config_dir(path.into());
}
return;
}
}
}
/// Merge two `:`-separated PATH lists, primary entries first, deduped, empties
/// dropped. Pure so it can be unit-tested; the env write stays in the caller.
#[cfg(unix)]
fn merge_paths(primary: &str, secondary: &str) -> String {
let mut seen = std::collections::HashSet::new();
primary
.split(':')
.chain(secondary.split(':'))
.filter(|p| !p.is_empty() && seen.insert(*p))
.collect::<Vec<_>>()
.join(":")
}
/// GUI apps launched from Finder/Dock inherit Launch Services' minimal PATH
/// (`/usr/bin:/bin:/usr/sbin:/sbin`), not the user's shell PATH — so the
/// completion engine's `$PATH` scan (`terminal::completion`) can't see
/// Homebrew/cargo/… executables and command candidates silently vanish. Ask the
/// user's login shell for its PATH once and merge it in front of ours (current
/// entries are kept: terminal launches may carry extras like direnv paths).
/// Login-but-not-interactive (`-l -c`) keeps it cheap: zsh reads .zprofile, not
/// .zshrc. Shells spawned by the daemon are unaffected either way — they are
/// login shells and rebuild PATH themselves.
#[cfg(unix)]
fn enrich_path_from_login_shell() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
// fish prints `$PATH` space-separated; ask it to join with ':' explicitly.
let cmd = if std::path::Path::new(&shell).file_name() == Some("fish".as_ref()) {
"string join ':' $PATH"
} else {
"echo $PATH"
};
let out = match std::process::Command::new(&shell)
.args(["-l", "-c", cmd])
.output()
{
Ok(out) if out.status.success() => out.stdout,
Ok(out) => {
log::warn!("login shell exited with {} while reading PATH", out.status);
return;
}
Err(e) => {
log::warn!("failed to spawn login shell {shell} for PATH: {e}");
return;
}
};
let login_path = String::from_utf8_lossy(&out).trim().to_string();
if login_path.is_empty() {
return;
}
let merged = merge_paths(&login_path, &std::env::var("PATH").unwrap_or_default());
// SAFETY: called from `main` before any thread is spawned, so no concurrent
// getenv can race the write.
unsafe { std::env::set_var("PATH", merged) };
}
fn main() {
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
// payload from stdin, writes one OSC sequence to the controlling terminal,
// and exits — never touching config, the daemon, or the GUI. Checked first
// so a hook can never accidentally boot a window.
{
let args: Vec<String> = std::env::args().skip(1).take(3).collect();
if args.first().map(String::as_str) == Some("agent-hook") {
if let [_, agent, event] = args.as_slice() {
crate::core::agent_hooks::run_agent_hook(agent, event);
}
return;
}
}
// Resolve the config directory override (if any) up front, before any code
// path touches config/session/history files (the daemon socket path resolves
// under this dir too, so the order matters).
apply_config_dir_arg();
// Daemon mode: when launched with `--daemon` we run the headless persistent
// terminal server and never open a window. This is the backing process the GUI
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
// the GUI. Run to completion (the accept loop blocks until killed) then return.
if std::env::args().any(|a| a == "--daemon") {
if let Err(e) = crate::daemon::server::run() {
log::error!("daemon exited with error: {e}");
}
return;
}
// Stop mode: `--stop-daemon` shuts the persistent daemon down (hanging up
// every shell) and returns without ever opening a window. On Windows the
// detached daemon is the running image of `tty7.exe`, so it locks the file
// and blocks an upgrade/uninstall from replacing it; the installer runs this
// first to release the lock. Harmless when no daemon is running.
if std::env::args().any(|a| a == "--stop-daemon") {
crate::daemon::spawn::stop();
return;
}
// GUI path: repair the starved Launch Services PATH before anything reads it
// (completion scans it per keystroke; the daemon we spawn below inherits it).
#[cfg(unix)]
enrich_path_from_login_shell();
// Make sure the persistent daemon is up before we open a window, so the
// very first RemoteTerminal can connect. This auto-spawns a detached
// daemon if none is running (sharing our config dir). Failure is non-fatal —
// we log and continue; a still-absent daemon will surface later when a
// RemoteTerminal fails to connect, rather than blocking startup here.
//
// When session restore is off, start the daemon *fresh* instead of reusing a
// live one: this launch won't re-attach to the previous session's panes, so
// reusing the daemon would leave those shells running orphaned (unreachable,
// never hung up). `restart()` hangs up every old shell then spawns a clean
// daemon — and is safe (equivalent to a plain spawn) when none is running.
// Read straight off disk; the `Config` global isn't set until inside `run`.
let restore_session = crate::core::config::Config::load().restore_session;
let daemon_result = if restore_session {
crate::daemon::spawn::ensure_running()
} else {
crate::daemon::spawn::restart()
};
if let Err(e) = daemon_result {
log::error!("failed to ensure daemon is running: {e}");
}
// Register the bundled icon/font asset source so gpui-component `Icon`s
// (tab glyphs, sidebar icons, etc.) can actually load their SVGs.
gpui_platform::application()
.with_assets(Assets)
.run(move |cx| {
gpui_component::init(cx);
register_bundled_fonts(cx);
cx.activate(true);
// Load user config once and stash it as a global for views to read.
cx.set_global(Config::load());
// Build the theme registry (built-ins + user theme files) before the
// first window paints its theme.
crate::ui::presets::load_registry(cx);
// Honor `mouse_hide_while_typing` from the start.
crate::ui::theme::apply_cursor_hide_mode(cx);
// Start watching `config.json` so edits hot-reload theme/colors live.
spawn_config_watcher(cx);
// Ask GitHub (once, in the background) whether a newer release exists;
// if so, Settings → About surfaces a download prompt. Fails soft and
// is a no-op when `check_for_updates` is disabled.
crate::core::update::spawn_check(cx);
keymap::init(cx);
cx.spawn(async move |cx| {
// Open at a roomy default, centred on the primary display (`centered`
// needs `&App`, which the async cx hands out via `update`).
let default_size = size(px(1440.), px(900.));
let bounds = cx.update(|cx| Bounds::centered(None, default_size, cx));
// Launch state from config: a normal centered window, or maximized /
// fullscreen. Each variant still carries the centered bounds as the
// size to restore to when the user un-maximizes / exits fullscreen.
let startup_mode = cx.update(|cx| cx.global::<Config>().startup_mode);
let window_bounds = match startup_mode {
crate::core::config::StartupMode::Normal => WindowBounds::Windowed(bounds),
crate::core::config::StartupMode::Maximized => WindowBounds::Maximized(bounds),
crate::core::config::StartupMode::Fullscreen => {
WindowBounds::Fullscreen(bounds)
}
};
let options = WindowOptions {
window_bounds: Some(window_bounds),
// Start from the component defaults but nudge the traffic lights
// down so they stay vertically centred in our taller (40px) title
// bar — see `TitleBar::new().h(..)` in `app.rs`. `apply_theme`
// re-pins the same position after appearance changes.
titlebar: Some(TitlebarOptions {
traffic_light_position: Some(crate::ui::theme::traffic_light_position()),
..TitleBar::title_bar_options()
}),
..Default::default()
};
cx.open_window(options, |window, cx| {
let app = cx.new(|cx| Tty7App::new(window, cx));
cx.new(|cx| Root::new(app, window, cx).bg(cx.theme().background))
})
.expect("failed to open window");
})
.detach();
});
}
#[cfg(all(test, unix))]
mod tests {
use super::merge_paths;
#[test]
fn merge_paths_prefers_primary_dedupes_and_drops_empties() {
assert_eq!(
merge_paths("/opt/homebrew/bin:/usr/bin", "/usr/bin:/bin:"),
"/opt/homebrew/bin:/usr/bin:/bin"
);
// A starved LS PATH gains the login entries up front.
assert_eq!(
merge_paths(
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
"/usr/bin:/bin"
),
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
);
assert_eq!(merge_paths("", "/usr/bin"), "/usr/bin");
}
}