diff --git a/.gitignore b/.gitignore index 059760a..2a03ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target /result /result-* +.desktop-share .direnv diff --git a/Cargo.lock b/Cargo.lock index 420d0cd..3c2f609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,6 +1173,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -1352,6 +1358,7 @@ dependencies = [ "eframe", "egui", "egui_term", + "serde_json", "vidya", ] @@ -2221,6 +2228,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3519,6 +3539,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index d0ce823..09da400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,5 @@ eframe = { version = "0.31", default-features = false, features = [ ] } egui = "0.31" egui_term = "0.1.0" +serde_json = "1" vidya = { path = "../vidya" } diff --git a/README.md b/README.md index 858e378..d1ea708 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,69 @@ Desktop GUI for running multiple interactive [`cursor-agent`](https://cursor.com) sessions. Themed with [vidya](../vidya) (egui), terminals via [`egui_term`](https://crates.io/crates/egui_term) (alacritty PTY). +## Run + +From this checkout (with sibling `../vidya`): + +```bash +nix run # apps.default → cargo build + gtk-launch .desktop (icon) +nix run .#desktop # same +nix run .#manager # cargo build + run binary only (no .desktop) +nix run .#build # cargo build --release only +nix run .#package # pure store binary +nix run .#package-desktop # pure package via its .desktop +``` + +`apps.default` / `apps.desktop` cargo-build, install `manager.desktop` + hicolor icons into **`$XDG_DATA_HOME`** (`~/.local/share` by default — required on Wayland/GNOME; a private share dir is invisible to the shell), refresh icon/desktop caches, then `gtk-launch manager`. The binary also embeds the PNG via `vidya::with_app_icon_id` (used on X11; Wayland window icons come from the FreeDesktop entry). Incremental builds go into `./target/`. + +Optional interactive shell: + +```bash +nix develop +cargo run --release +``` + +Packaged install (pure Nix derivation — may use remote builders): + +```bash +nix build .#manager +./result/bin/manager +# also: result/share/applications/manager.desktop +# result/share/icons/hicolor/.../apps/manager.{svg,png} +``` + +**New session** opens a dialog for workspace, optional model / prompt, and `--trust` / `--force`. **Resume** lists past chats from `~/.cursor/chats` and spawns with `--resume `. Each session is a full agent TTY in a tab; **Close** removes the active tab. + +**Paste images** with Ctrl+V in an active session (screenshot / image on the clipboard). The manager forwards `^V` to cursor-agent, which attaches the image via `wl-paste` / `xclip`. + ## Requirements - Rust toolchain - `cursor-agent` or `agent` on `PATH` (or set `CURSOR_AGENT` to the binary path) - Linux (Wayland or X11) +- `wl-paste` (Wayland) or `xclip` (X11) on `PATH` for clipboard image paste -## Run +## Nix -```bash -nix run # apps.default → cargo run + GL/Wayland libs -nix run .#manager -# or: -cargo run -nix develop # same libs on LD_LIBRARY_PATH -``` - -Run from this checkout (sibling to `../vidya` — required by the path dependency). +| Output | Role | +|--------|------| +| `apps.default` / `apps.desktop` | Devshell: cargo build + `gtk-launch` `.desktop` (icon) | +| `apps.manager` | Devshell: cargo build + run binary only | +| `apps.build` | Devshell: `cargo build --release` only | +| `apps.package` | Pure store binary | +| `apps.package-desktop` | Pure package via its `.desktop` | +| `packages.default` / `packages.manager` | Binary + `.desktop` + hicolor icons | +| `packages.desktop` | Wrapper that launches the packaged `.desktop` | +| `devShells.default` | rustc + cargo + egui runtime libs | +| `assets/manager.svg` | App icon source | -**New session** opens a dialog for workspace, optional model / prompt, and `--trust` / `--force`. Each session is a full agent TUI in a tab; **Close** removes the active tab. +Apps need a live checkout next to `../vidya`. The packaged `packages.manager` stages flake input `vidya` for pure builds. ## Notes - PTY I/O is handled by `egui_term` / alacritty’s tty (same role as `portable-pty`). - Theme path-depends on `../vidya` at egui 0.31. + +## License + +MIT diff --git a/assets/manager-128.png b/assets/manager-128.png new file mode 100644 index 0000000000000000000000000000000000000000..892c17b50ccd50c548c53db3d25362ba53bfbe36 GIT binary patch literal 3576 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 literal 0 HcmV?d00001 diff --git a/assets/manager-256.png b/assets/manager-256.png new file mode 100644 index 0000000000000000000000000000000000000000..4b97e801fca6fc41cb90ffc5d82e2bf97a9edf10 GIT binary patch literal 7375 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 literal 0 HcmV?d00001 diff --git a/assets/manager.svg b/assets/manager.svg new file mode 100644 index 0000000..4456828 --- /dev/null +++ b/assets/manager.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flake.lock b/flake.lock index e6d08bb..7a92a07 100644 --- a/flake.lock +++ b/flake.lock @@ -18,7 +18,24 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "vidya": "vidya" + } + }, + "vidya": { + "flake": false, + "locked": { + "lastModified": 1785542525, + "narHash": "sha256-WcA5jn6nlqWpXe5sXoLa/BP5ByVWdDpPTpZaSay96P4=", + "ref": "refs/heads/main", + "rev": "12cc7dd2a8e860e9599448465be7b1362fdf7d88", + "revCount": 21, + "type": "git", + "url": "https://tangled.org/nandi.uk/vidya" + }, + "original": { + "type": "git", + "url": "https://tangled.org/nandi.uk/vidya" } } }, diff --git a/flake.nix b/flake.nix index 4facf5d..9dae173 100644 --- a/flake.nix +++ b/flake.nix @@ -1,19 +1,25 @@ { description = "Agent Manager — multi-instance cursor-agent GUI (vidya + egui_term)"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Fetched for pure `nix build` / `nix profile add` (git+file cannot see + # siblings). Local cargo apps still use Cargo.toml path = "../vidya". + # Override while hacking: --override-input vidya path:../vidya + vidya = { + url = "git+https://tangled.org/nandi.uk/vidya"; + flake = false; + }; + }; outputs = - { self, nixpkgs }: + { self, nixpkgs, vidya }: let systems = [ "x86_64-linux" "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs systems; - pkgsFor = system: nixpkgs.legacyPackages.${system}; eguiLibs = pkgs: @@ -30,93 +36,282 @@ libxi libxrandr ]; + + # Shared by cargo apps: enter checkout + set link path. + cargoPreamble = libPath: '' + set -euo pipefail + export LD_LIBRARY_PATH="${libPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + + if [ ! -f Cargo.toml ] && [ -f "''${FLAKE_ROOT:-}/Cargo.toml" ]; then + cd "$FLAKE_ROOT" + fi + if [ ! -f Cargo.toml ]; then + echo "manager: no Cargo.toml here (cwd=$PWD)" >&2 + echo " cd into the manager checkout, then: nix run .#manager" >&2 + exit 1 + fi + if [ ! -d ../vidya ]; then + echo "manager: expected sibling ../vidya (Cargo path dep)" >&2 + echo " clone vidya next to manager, or: nix build .#manager" >&2 + exit 1 + fi + if [ ! -f assets/manager.svg ] || [ ! -f manager.desktop ]; then + echo "manager: missing assets/manager.svg or manager.desktop" >&2 + exit 1 + fi + ''; + + # Install FreeDesktop entry + hicolor icons under a share root (usually + # $XDG_DATA_HOME). Wayland compositors (GNOME) look up icons by app_id via + # the *session* data dirs — a temporary XDG_DATA_DIRS on the child alone + # is not enough. Absolute Icon= path is the reliable dock/overview path. + # Shell fragment: $1=shareDir $2=execPath (absolute). + installDesktopShareSh = '' + _share="$1" + _exec="$2" + _icon="$_share/icons/hicolor/256x256/apps/manager.png" + mkdir -p "$_share/applications" + mkdir -p "$_share/icons/hicolor/scalable/apps" + cp assets/manager.svg "$_share/icons/hicolor/scalable/apps/manager.svg" + for sz in 16 24 32 48 64 128 256; do + mkdir -p "$_share/icons/hicolor/''${sz}x''${sz}/apps" + rsvg-convert -w "$sz" -h "$sz" assets/manager.svg \ + -o "$_share/icons/hicolor/''${sz}x''${sz}/apps/manager.png" + done + sed -e "s|@EXEC@|''${_exec}|g" -e "s|@ICON@|''${_icon}|g" manager.desktop \ + > "$_share/applications/manager.desktop" + # Help icon themes / menus pick up the new files this session. + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -f "$_share/icons/hicolor" 2>/dev/null || true + fi + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$_share/applications" 2>/dev/null || true + fi + ''; in { packages = forAllSystems ( system: let - pkgs = pkgsFor system; + pkgs = nixpkgs.legacyPackages.${system}; inherit (pkgs) lib; libs = eguiLibs pkgs; - libPath = lib.makeLibraryPath libs; - # `cargo run` launcher: rustup's cargo + nix-provided GL/Wayland libs. - # Prefers a live checkout (cwd); falls back to the flake source in the store. - # Path-dep `../vidya` requires the manager tree to sit next to a vidya checkout - # (cwd case). Store fallback only works if that sibling layout is preserved. - managerRunner = pkgs.writeShellApplication { - name = "manager"; - text = '' - export LD_LIBRARY_PATH="${libPath}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - export PATH="''${HOME}/.cargo/bin:$PATH" + # Packaged binary for `nix build` / install — pure, remote-builder capable. + srcTree = pkgs.runCommand "manager-src" { } '' + mkdir -p $out/manager $out/vidya + cp -a ${lib.cleanSource ./.}/. $out/manager/ + cp -a ${vidya}/. $out/vidya/ + chmod -R u+w $out + rm -rf $out/manager/{target,result,result-*,.git,.jj,.desktop-share} 2>/dev/null || true + rm -rf $out/vidya/{target,android-demo,host,examples,docs,.git,.jj} 2>/dev/null || true + ''; - if ! command -v cargo >/dev/null 2>&1; then - echo "manager: cargo not found — install rustup or put cargo on PATH" >&2 - exit 127 - fi + manager = pkgs.rustPlatform.buildRustPackage { + pname = "manager"; + version = "0.1.0"; + src = srcTree; + sourceRoot = "manager-src/manager"; + cargoLock.lockFile = ./Cargo.lock; - run_manager() { - local root=$1 - shift - if [ ! -d "$root/../vidya" ]; then - echo "manager: expected sibling checkout at $root/../vidya" >&2 - echo " keep manager next to vidya (path dep in Cargo.toml)" >&2 - exit 1 - fi - exec cargo run --manifest-path "$root/Cargo.toml" -- "$@" - } - - # nix run keeps the caller's cwd → live edit/rebuild against the tree - if [ -f Cargo.toml ] && [ -d src ]; then - run_manager "$PWD" "$@" - fi + nativeBuildInputs = [ + pkgs.makeWrapper + pkgs.librsvg + ]; + buildInputs = libs; - # App launcher / nix run from another directory → flake source (read-only) - flake_src=${lib.escapeShellArg (toString self)} - if [ -f "$flake_src/Cargo.toml" ]; then - export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-''${XDG_CACHE_HOME:-$HOME/.cache}/manager/cargo-target}" - run_manager "$flake_src" "$@" - fi + postInstall = '' + wrapProgram $out/bin/manager \ + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath libs} + + install -Dm644 ${./assets/manager.svg} \ + $out/share/icons/hicolor/scalable/apps/manager.svg + for sz in 16 24 32 48 64 128 256; do + mkdir -p $out/share/icons/hicolor/''${sz}x''${sz}/apps + rsvg-convert -w "$sz" -h "$sz" ${./assets/manager.svg} \ + -o $out/share/icons/hicolor/''${sz}x''${sz}/apps/manager.png + done - echo "manager: Cargo.toml not found" >&2 - echo " run from the manager checkout (next to ../vidya), or: nix run path:." >&2 - exit 1 + install -d $out/share/applications + substitute ${./manager.desktop} $out/share/applications/manager.desktop \ + --replace-fail '@EXEC@' "$out/bin/manager" \ + --replace-fail '@ICON@' "$out/share/icons/hicolor/256x256/apps/manager.png" + ''; + + meta = { + description = "Multi-instance cursor-agent manager (vidya + egui_term)"; + homepage = "https://tangled.org/nandi.uk/manager"; + license = lib.licenses.mit; + mainProgram = "manager"; + platforms = lib.platforms.linux; + }; + }; + + # Install store .desktop+icons into the user data home, then gtk-launch. + # (GNOME Shell only resolves icons from session-known data dirs.) + desktop = pkgs.writeShellApplication { + name = "manager-desktop"; + runtimeInputs = [ + pkgs.gtk3 + pkgs.desktop-file-utils + ]; + text = '' + set -euo pipefail + DATA_HOME="''${XDG_DATA_HOME:-$HOME/.local/share}" + mkdir -p "$DATA_HOME/applications" "$DATA_HOME/icons/hicolor" + cp -a ${manager}/share/icons/hicolor/. "$DATA_HOME/icons/hicolor/" + ICON="$DATA_HOME/icons/hicolor/256x256/apps/manager.png" + sed -e "s|^Exec=.*|Exec=${manager}/bin/manager|" \ + -e "s|^Icon=.*|Icon=$ICON|" \ + ${manager}/share/applications/manager.desktop \ + > "$DATA_HOME/applications/manager.desktop" + gtk-update-icon-cache -f "$DATA_HOME/icons/hicolor" 2>/dev/null || true + update-desktop-database "$DATA_HOME/applications" 2>/dev/null || true + export XDG_DATA_DIRS="$DATA_HOME''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" + if command -v gtk-launch >/dev/null 2>&1; then + exec gtk-launch manager "$@" + fi + exec ${manager}/bin/manager "$@" ''; }; in - rec { - manager = managerRunner; + { default = manager; + manager = manager; + desktop = desktop; } ); apps = forAllSystems ( system: let - manager = self.packages.${system}.manager; + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + libs = eguiLibs pkgs; + libPath = lib.makeLibraryPath libs; + cargoTools = with pkgs; [ + rustc + cargo + pkg-config + librsvg + gtk3 + desktop-file-utils # update-desktop-database + ]; + + # Local cargo build (devshell tools; no pure sandbox / remote upload lock). + build = pkgs.writeShellApplication { + name = "manager-build"; + runtimeInputs = cargoTools; + text = '' + ${cargoPreamble libPath} + echo "→ cargo build --release $*" + cargo build --release "$@" + echo "✓ target/release/manager" + ''; + }; + + # Build (if needed) and run via the same toolchain as `nix develop`. + managerApp = pkgs.writeShellApplication { + name = "manager"; + runtimeInputs = cargoTools; + text = '' + ${cargoPreamble libPath} + echo "→ cargo build --release" + cargo build --release + echo "→ target/release/manager $*" + exec ./target/release/manager "$@" + ''; + }; + + # Cargo build, install .desktop+icons into XDG_DATA_HOME (so GNOME/Wayland + # can resolve Icon= by app_id), then gtk-launch. + desktopApp = pkgs.writeShellApplication { + name = "manager-desktop"; + runtimeInputs = cargoTools; + text = '' + ${cargoPreamble libPath} + echo "→ cargo build --release" + cargo build --release + + EXEC="$PWD/target/release/manager" + # Session-visible FreeDesktop tree (not a private .desktop-share): + # Wayland shells ignore child-only XDG_DATA_DIRS for dock icons. + DATA_HOME="''${XDG_DATA_HOME:-$HOME/.local/share}" + echo "→ install launcher+icon → $DATA_HOME ({applications,icons}/…)" + ( + set -- "$DATA_HOME" "$EXEC" + ${installDesktopShareSh} + ) + # Mirror under .desktop-share for inspection / CI. + SHARE="''${MANAGER_DESKTOP_SHARE:-$PWD/.desktop-share}" + rm -rf "$SHARE" + mkdir -p "$SHARE" + ( + set -- "$SHARE" "$EXEC" + ${installDesktopShareSh} + ) + + export XDG_DATA_DIRS="$DATA_HOME''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" + ICON="$DATA_HOME/icons/hicolor/256x256/apps/manager.png" + echo "→ gtk-launch manager (Icon=$ICON, Exec=$EXEC, app_id=manager)" + if command -v gtk-launch >/dev/null 2>&1; then + exec gtk-launch manager "$@" + fi + exec "$EXEC" "$@" + ''; + }; in { + # Default: cargo build + gtk-launch .desktop (icon + FreeDesktop metadata). + default = { + type = "app"; + program = "${desktopApp}/bin/manager-desktop"; + }; + desktop = { + type = "app"; + program = "${desktopApp}/bin/manager-desktop"; + }; + # Binary only (no .desktop / icon path). manager = { type = "app"; - program = "${manager}/bin/manager"; + program = "${managerApp}/bin/manager"; + }; + build = { + type = "app"; + program = "${build}/bin/manager-build"; + }; + # Pure store package launched via its .desktop (needs nix build). + package-desktop = { + type = "app"; + program = "${self.packages.${system}.desktop}/bin/manager-desktop"; + }; + package = { + type = "app"; + program = "${self.packages.${system}.manager}/bin/manager"; }; - default = self.apps.${system}.manager; } ); devShells = forAllSystems ( system: let - pkgs = pkgsFor system; + pkgs = nixpkgs.legacyPackages.${system}; libs = eguiLibs pkgs; in { default = pkgs.mkShell { + packages = with pkgs; [ + rustc + cargo + rustfmt + clippy + pkg-config + librsvg + ]; buildInputs = libs; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath libs; shellHook = '' - export PATH="$HOME/.cargo/bin:$PATH" - echo "manager — nix run | cargo run" + echo "manager — nix run (.desktop+icon) | nix run .#manager | nix run .#build | nix develop" ''; }; } diff --git a/manager.desktop b/manager.desktop new file mode 100644 index 0000000..24050ba --- /dev/null +++ b/manager.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Agent Manager +GenericName=Agent Sessions +Comment=Multi-instance cursor-agent GUI +Exec=@EXEC@ +Icon=@ICON@ +Categories=Development;TerminalEmulator; +Keywords=agent;cursor;terminal;session;vidya;manager; +Terminal=false +StartupNotify=true +StartupWMClass=manager diff --git a/src/app.rs b/src/app.rs index 0908b24..ec9fbd7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,95 @@ //! Main window: vidya chrome, session tabs, embedded agent terminals. -use crate::session::{AgentSession, NewSessionDraft}; -use egui_term::{ColorPalette, PtyEvent, TerminalTheme, TerminalView}; +use crate::session::{list_saved_chats, AgentSession, NewSessionDraft, SavedChat}; +use egui_term::{BackendCommand, ColorPalette, PtyEvent, TerminalTheme, TerminalView}; use std::collections::BTreeMap; use std::sync::mpsc::{self, Receiver, Sender}; use vidya::Theme; +struct ResumeDialog { + filter: String, + chats: Vec, + selected: Option, + model: String, + trust: bool, + force: bool, + load_error: Option, +} + +struct RenameDialog { + id: u64, + draft: String, + /// Request focus once when the dialog opens. + focus: bool, +} + +impl ResumeDialog { + fn load() -> Self { + match list_saved_chats() { + Ok(chats) => Self { + filter: String::new(), + chats, + selected: None, + model: String::new(), + trust: true, + force: false, + load_error: None, + }, + Err(err) => Self { + filter: String::new(), + chats: Vec::new(), + selected: None, + model: String::new(), + trust: true, + force: false, + load_error: Some(err), + }, + } + } + + fn filtered(&self) -> Vec<&SavedChat> { + let q = self.filter.trim().to_lowercase(); + self.chats + .iter() + .filter(|c| { + if q.is_empty() { + return true; + } + c.title.to_lowercase().contains(&q) + || c.workspace_label().to_lowercase().contains(&q) + || c.id.to_lowercase().contains(&q) + }) + .collect() + } + + fn selected_chat(&self) -> Option<&SavedChat> { + let id = self.selected.as_deref()?; + self.chats.iter().find(|c| c.id == id) + } + + fn to_draft(&self) -> Result { + let chat = self + .selected_chat() + .ok_or_else(|| "select a chat to resume".to_string())?; + let workspace = chat + .cwd + .clone() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| { + "selected chat has no workspace path; cannot resume".to_string() + })?; + Ok(NewSessionDraft { + workspace, + model: self.model.clone(), + prompt: String::new(), + trust: self.trust, + force: self.force, + resume_chat_id: Some(chat.id.clone()), + tab_title: Some(chat.title.clone()), + }) + } +} + pub struct App { theme: Theme, next_id: u64, @@ -14,6 +98,8 @@ pub struct App { pty_tx: Sender<(u64, PtyEvent)>, pty_rx: Receiver<(u64, PtyEvent)>, new_dialog: Option, + resume_dialog: Option, + rename_dialog: Option, spawn_error: Option, term_theme: TerminalTheme, } @@ -29,11 +115,19 @@ impl App { pty_tx, pty_rx, new_dialog: None, + resume_dialog: None, + rename_dialog: None, spawn_error: None, term_theme: vidya_term_theme(), } } + fn dialog_open(&self) -> bool { + self.new_dialog.is_some() + || self.resume_dialog.is_some() + || self.rename_dialog.is_some() + } + fn poll_pty_events(&mut self) { while let Ok((id, event)) = self.pty_rx.try_recv() { match event { @@ -47,7 +141,7 @@ impl App { } PtyEvent::Title(title) => { if let Some(session) = self.sessions.get_mut(&id) { - if session.alive { + if session.alive && !session.title_locked { session.title = title; } } @@ -57,7 +151,7 @@ impl App { } } - fn spawn_from_draft(&mut self, ctx: &egui::Context, draft: NewSessionDraft) { + fn spawn_from_draft(&mut self, ctx: &egui::Context, draft: NewSessionDraft) -> bool { let id = self.next_id; self.next_id += 1; match AgentSession::spawn(id, ctx.clone(), self.pty_tx.clone(), &draft) { @@ -65,12 +159,13 @@ impl App { self.sessions.insert(id, session); self.active = Some(id); self.new_dialog = None; + self.resume_dialog = None; self.spawn_error = None; + true } Err(err) => { self.spawn_error = Some(err); - // Keep dialog open so the user can fix the path / flags. - self.new_dialog = Some(draft); + false } } } @@ -97,6 +192,7 @@ impl App { .and_then(|id| self.sessions.get(&id)) .map(|s| format!("#{} · {}", s.id, s.workspace.display())); let mut open_new = false; + let mut open_resume = false; let mut close = false; vidya::top_header(ctx, &theme, |ui| { @@ -113,6 +209,10 @@ impl App { close = true; } ui.add_space(theme.spacing.sm); + if vidya::button(ui, &theme, "Resume").clicked() { + open_resume = true; + } + ui.add_space(theme.spacing.sm); if vidya::primary_button(ui, &theme, "New session").clicked() { open_new = true; } @@ -122,6 +222,12 @@ impl App { if open_new { self.new_dialog = Some(NewSessionDraft::default()); + self.resume_dialog = None; + self.spawn_error = None; + } + if open_resume { + self.resume_dialog = Some(ResumeDialog::load()); + self.new_dialog = None; self.spawn_error = None; } if close { @@ -137,6 +243,8 @@ impl App { let theme = self.theme.clone(); let ids: Vec = self.sessions.keys().copied().collect(); let mut select: Option = None; + let mut auto_rename: Option = None; + let mut open_rename: Option = None; egui::TopBottomPanel::top("session_tabs") .frame( @@ -177,9 +285,23 @@ impl App { .stroke(egui::Stroke::new(1.0, theme.palette.border_soft)) .corner_radius(theme.spacing.radius_md) .min_size(egui::vec2(0.0, theme.spacing.control_height)); - if ui.add(btn).clicked() { + let response = ui.add(btn); + if response.clicked() { select = Some(id); } + if response.secondary_clicked() { + select = Some(id); + } + response.context_menu(|ui| { + if ui.button("Auto-rename from content").clicked() { + auto_rename = Some(id); + ui.close_menu(); + } + if ui.button("Rename…").clicked() { + open_rename = Some(id); + ui.close_menu(); + } + }); } }); }); @@ -187,6 +309,89 @@ impl App { if let Some(id) = select { self.active = Some(id); } + if let Some(id) = auto_rename { + if let Some(session) = self.sessions.get_mut(&id) { + session.auto_rename_from_content(); + } + } + if let Some(id) = open_rename { + if let Some(session) = self.sessions.get(&id) { + let mut draft = session.title.clone(); + if let Some(base) = draft.strip_suffix(" (exited)") { + draft = base.to_string(); + } + self.rename_dialog = Some(RenameDialog { + id, + draft, + focus: true, + }); + } + } + } + + fn show_rename_dialog(&mut self, ctx: &egui::Context) { + let Some(mut dialog) = self.rename_dialog.take() else { + return; + }; + if !self.sessions.contains_key(&dialog.id) { + return; + } + + let theme = self.theme.clone(); + let mut apply = false; + let mut cancel = false; + let mut keep_open = true; + + egui::Window::new("Rename tab") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame(theme.card_frame()) + .show(ctx, |ui| { + ui.set_min_width(360.0); + vidya::title_2(ui, &theme, "Tab title"); + ui.add_space(theme.spacing.sm); + let response = vidya::text_field_singleline(ui, &theme, &mut dialog.draft); + if dialog.focus { + response.request_focus(); + dialog.focus = false; + } + if response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && !dialog.draft.trim().is_empty() + { + apply = true; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } + + ui.add_space(theme.spacing.md); + ui.horizontal(|ui| { + ui.add_enabled_ui(!dialog.draft.trim().is_empty(), |ui| { + if vidya::primary_button(ui, &theme, "Rename").clicked() { + apply = true; + } + }); + if vidya::button(ui, &theme, "Cancel").clicked() { + cancel = true; + } + }); + }); + + if cancel { + keep_open = false; + } + if apply { + let title = dialog.draft.trim().to_string(); + if let Some(session) = self.sessions.get_mut(&dialog.id) { + session.set_user_title(title); + } + return; + } + if keep_open { + self.rename_dialog = Some(dialog); + } } fn show_new_dialog(&mut self, ctx: &egui::Context) { @@ -242,7 +447,9 @@ impl App { self.spawn_error = None; } if spawn { - self.spawn_from_draft(ctx, draft); + if !self.spawn_from_draft(ctx, draft.clone()) { + self.new_dialog = Some(draft); + } return; } if keep_open { @@ -250,10 +457,191 @@ impl App { } } + fn show_resume_dialog(&mut self, ctx: &egui::Context) { + let Some(mut dialog) = self.resume_dialog.take() else { + return; + }; + + let theme = self.theme.clone(); + let mut resume = false; + let mut cancel = false; + let mut refresh = false; + let mut keep_open = true; + let error = self.spawn_error.clone(); + let filtered: Vec = dialog.filtered().into_iter().cloned().collect(); + let selected = dialog.selected.clone(); + + egui::Window::new("Resume session") + .collapsible(false) + .resizable(true) + .default_size([560.0, 480.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame(theme.card_frame()) + .show(ctx, |ui| { + ui.set_min_width(480.0); + ui.set_min_height(360.0); + vidya::title_2(ui, &theme, "Past cursor-agent chats"); + ui.add_space(theme.spacing.sm); + ui.horizontal(|ui| { + vidya::dim_label(ui, &theme, "Filter"); + ui.add_space(theme.spacing.sm); + let _ = vidya::text_field_singleline(ui, &theme, &mut dialog.filter); + if vidya::button(ui, &theme, "Refresh").clicked() { + refresh = true; + } + }); + ui.add_space(theme.spacing.sm); + + if let Some(err) = &dialog.load_error { + ui.colored_label(theme.palette.destructive, err); + ui.add_space(theme.spacing.sm); + } + + let list_height = (ui.available_height() - 140.0).max(160.0); + egui::ScrollArea::vertical() + .max_height(list_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + if filtered.is_empty() { + vidya::dim_label(ui, &theme, "No saved chats match."); + return; + } + for chat in &filtered { + let is_sel = selected.as_deref() == Some(chat.id.as_str()); + let title = egui::RichText::new(&chat.title) + .size(theme.type_scale.body) + .color(if is_sel { + theme.palette.accent_fg + } else { + theme.palette.text + }); + let meta = format!( + "{} · {} · {}", + chat.workspace_label(), + chat.age_label(), + &chat.id[..chat.id.len().min(8)] + ); + let fill = if is_sel { + theme.palette.accent + } else { + theme.palette.popover_bg + }; + let stroke = egui::Stroke::new( + 1.0, + if is_sel { + theme.palette.accent + } else { + theme.palette.border_soft + }, + ); + let row = egui::Frame::NONE + .fill(fill) + .stroke(stroke) + .corner_radius(theme.spacing.radius_sm) + .inner_margin(egui::Margin::symmetric( + theme.spacing.md as i8, + theme.spacing.sm as i8, + )) + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(title); + ui.label( + egui::RichText::new(meta) + .size(theme.type_scale.caption) + .color(if is_sel { + theme.palette.accent_fg + } else { + theme.palette.text_secondary + }), + ); + }); + }); + if row.response.interact(egui::Sense::click()).clicked() { + dialog.selected = Some(chat.id.clone()); + } + ui.add_space(theme.spacing.xs); + } + }); + + ui.add_space(theme.spacing.sm); + vidya::dim_label(ui, &theme, "Model (optional)"); + vidya::text_field_singleline(ui, &theme, &mut dialog.model); + ui.add_space(theme.spacing.sm); + vidya::checkbox(ui, &theme, &mut dialog.trust, "Trust workspace (--trust)"); + vidya::checkbox(ui, &theme, &mut dialog.force, "Force / yolo (--force)"); + + if let Some(err) = &error { + ui.add_space(theme.spacing.sm); + ui.colored_label(theme.palette.destructive, err); + } + + ui.add_space(theme.spacing.md); + ui.horizontal(|ui| { + let can_resume = dialog.selected.is_some(); + ui.add_enabled_ui(can_resume, |ui| { + if vidya::primary_button(ui, &theme, "Resume").clicked() { + resume = true; + } + }); + if vidya::button(ui, &theme, "Cancel").clicked() { + cancel = true; + } + }); + }); + + if cancel { + keep_open = false; + self.spawn_error = None; + } + if refresh { + let filter = dialog.filter; + let model = dialog.model; + let trust = dialog.trust; + let force = dialog.force; + let selected = dialog.selected; + let mut next = ResumeDialog::load(); + next.filter = filter; + next.model = model; + next.trust = trust; + next.force = force; + if selected + .as_ref() + .is_some_and(|id| next.chats.iter().any(|c| &c.id == id)) + { + next.selected = selected; + } + self.resume_dialog = Some(next); + self.spawn_error = None; + return; + } + if resume { + match dialog.to_draft() { + Ok(draft) => { + if !self.spawn_from_draft(ctx, draft) { + self.resume_dialog = Some(dialog); + } + return; + } + Err(err) => { + self.spawn_error = Some(err); + self.resume_dialog = Some(dialog); + return; + } + } + } + if keep_open { + self.resume_dialog = Some(dialog); + } + } + fn show_central(&mut self, ctx: &egui::Context) { let theme = self.theme.clone(); let term_theme = self.term_theme.clone(); let active = self.active; + // egui_term's set_focus(true) calls request_focus() every frame, which would + // steal keys from dialogs (new session / resume filter fields). + let term_focus = !self.dialog_open(); egui::CentralPanel::default() .frame(egui::Frame::NONE.fill(theme.palette.view_bg)) @@ -261,7 +649,7 @@ impl App { if let Some(id) = active { if let Some(session) = self.sessions.get_mut(&id) { let terminal = TerminalView::new(ui, &mut session.backend) - .set_focus(session.alive) + .set_focus(session.alive && term_focus) .set_theme(term_theme.clone()) .set_size(ui.available_size()); ui.add(terminal); @@ -278,9 +666,70 @@ impl App { &theme, "New session opens an interactive cursor-agent PTY.", ); + ui.add_space(theme.spacing.xs); + vidya::dim_label( + ui, + &theme, + "Resume reopens a past chat from ~/.cursor/chats.", + ); }); }); } + + /// Forward Ctrl/Cmd+V to the agent as `^V` so it can attach clipboard images. + /// + /// egui-winit treats Ctrl+V as a paste shortcut and only emits `Event::Paste` when the + /// clipboard has text. Image-only clipboards therefore produce neither Paste nor a Key + /// press — only the Key *release* survives. We send `^V` on that release (cursor-agent + /// then reads the image via wl-paste/xclip). Text pastes are also deferred to release: + /// we drop the egui Paste event so egui_term does not double-send `^V`. + fn handle_agent_paste(&mut self, ctx: &egui::Context) { + // Dialog fields need normal egui text paste. + if self.dialog_open() { + return; + } + + let (steal_paste, send_caret_v) = ctx.input(|i| { + let steal_paste = i.modifiers.command && !i.modifiers.shift; + let send_caret_v = i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { + key: egui::Key::V, + pressed: false, + modifiers, + .. + } if modifiers.command && !modifiers.shift && !modifiers.alt + ) + }); + (steal_paste, send_caret_v) + }); + + if steal_paste { + ctx.input_mut(|i| { + i.events + .retain(|e| !matches!(e, egui::Event::Paste(_))); + }); + } + + if !send_caret_v { + return; + } + + let Some(id) = self.active else { + return; + }; + let Some(session) = self.sessions.get_mut(&id) else { + return; + }; + if !session.alive { + return; + } + + session + .backend + .process_command(BackendCommand::Write(vec![0x16])); + } } impl eframe::App for App { @@ -293,10 +742,14 @@ impl eframe::App for App { } self.poll_pty_events(); + // Before the terminal widget reads input, so we can steal Paste events. + self.handle_agent_paste(ctx); self.show_header(ctx); self.show_tabs(ctx); self.show_central(ctx); self.show_new_dialog(ctx); + self.show_resume_dialog(ctx); + self.show_rename_dialog(ctx); } } diff --git a/src/main.rs b/src/main.rs index dc6e086..0fb93d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,14 +2,25 @@ mod app; mod session; +mod subagents; + +use vidya::with_app_icon_id; + +/// Window / FreeDesktop icon (256² PNG; source SVG in `assets/manager.svg`). +const APP_ICON_PNG: &[u8] = include_bytes!("../assets/manager-256.png"); fn main() -> eframe::Result { - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() + let viewport = with_app_icon_id( + egui::ViewportBuilder::default() .with_title("Agent Manager") - .with_app_id("agent-manager") .with_inner_size([1100.0, 720.0]) .with_min_inner_size([640.0, 420.0]), + "manager", // Wayland app_id ↔ manager.desktop StartupWMClass + APP_ICON_PNG, + ); + + let native_options = eframe::NativeOptions { + viewport, ..Default::default() }; diff --git a/src/session.rs b/src/session.rs index 271cce4..b59e78d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,11 +1,14 @@ //! Resolve and spawn cursor-agent sessions into PTYs via egui_term. +use crate::subagents::{self, Subagent}; use egui_term::{BackendSettings, PtyEvent, TerminalBackend}; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::Sender; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// Draft fields for the new-session dialog. +/// Draft fields for the new-session / resume dialogs. #[derive(Debug, Clone)] pub struct NewSessionDraft { pub workspace: String, @@ -13,6 +16,10 @@ pub struct NewSessionDraft { pub prompt: String, pub trust: bool, pub force: bool, + /// When set, spawn with `--resume ` (initial prompt is ignored). + pub resume_chat_id: Option, + /// Tab title override (e.g. saved chat title when resuming). + pub tab_title: Option, } impl Default for NewSessionDraft { @@ -26,6 +33,30 @@ impl Default for NewSessionDraft { prompt: String::new(), trust: true, force: false, + resume_chat_id: None, + tab_title: None, + } + } +} + +/// A past cursor-agent chat from `~/.cursor/chats`. +#[derive(Debug, Clone)] +pub struct SavedChat { + pub id: String, + pub title: String, + pub cwd: Option, + pub updated_at_ms: u64, +} + +impl SavedChat { + pub fn age_label(&self) -> String { + format_age(self.updated_at_ms) + } + + pub fn workspace_label(&self) -> String { + match &self.cwd { + Some(cwd) if !cwd.is_empty() => cwd.clone(), + _ => "(no workspace)".into(), } } } @@ -37,6 +68,12 @@ pub struct AgentSession { pub workspace: PathBuf, pub backend: TerminalBackend, pub alive: bool, + /// Cursor chat id (`create-chat` or resume); used for title + subagent polling. + pub chat_id: String, + /// Nested Task/subagents discovered under this chat. + pub subagents: Vec, + /// When true, ignore OSC / PTY title updates so a user rename sticks. + pub title_locked: bool, } impl AgentSession { @@ -58,12 +95,31 @@ impl AgentSession { } let shell = resolve_agent_binary()?; - let args = build_args(draft, &workspace); - let title = workspace - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("agent") - .to_string(); + let chat_id = match draft + .resume_chat_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(id) => id.to_string(), + None => subagents::create_chat(&shell, &workspace)?, + }; + let mut draft = draft.clone(); + draft.resume_chat_id = Some(chat_id.clone()); + let args = build_args(&draft, &workspace); + let title = draft + .tab_title + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + workspace + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("agent") + .to_string() + }); let backend = TerminalBackend::new( id, @@ -83,11 +139,161 @@ impl AgentSession { workspace, backend, alive: true, + chat_id, + subagents: Vec::new(), + title_locked: false, }) } + + /// Refresh title (if unlocked) and subagent list from Cursor's chat store. + pub fn poll_chat_state(&mut self) -> bool { + let snap = subagents::poll_chat(&self.chat_id); + let mut changed = false; + + if !self.title_locked { + if let Some(title) = snap.title { + let next = if self.alive { + title + } else if title.ends_with(" (exited)") { + title + } else { + format!("{title} (exited)") + }; + if self.title != next { + self.title = next; + changed = true; + } + } + } + + if self.subagents != snap.subagents { + self.subagents = snap.subagents; + changed = true; + } + changed + } + + /// Set tab title from a user action (manual or auto-rename). + pub fn set_user_title(&mut self, title: impl Into) { + let mut title = title.into().trim().to_string(); + if title.is_empty() { + return; + } + if !self.alive && !title.ends_with(" (exited)") { + title.push_str(" (exited)"); + } + self.title = title; + self.title_locked = true; + } + + /// Derive a tab title from Cursor chat meta and/or visible terminal text. + pub fn auto_rename_from_content(&mut self) { + if let Some(title) = suggest_title_from_content(self) { + self.set_user_title(title); + } + } +} + +/// Prefer Cursor's chat title for this session; fall back to terminal text. +fn suggest_title_from_content(session: &mut AgentSession) -> Option { + if let Some(title) = lookup_chat_title_for_session(session) { + return Some(title); + } + title_from_terminal(&mut session.backend) +} + +fn lookup_chat_title_for_session(session: &AgentSession) -> Option { + let chats = list_saved_chats().ok()?; + if let Some(chat) = chats.iter().find(|c| c.id == session.chat_id) { + let title = chat.title.trim(); + if !title.is_empty() && title != "Untitled" { + return Some(title.to_string()); + } + } + + let ws = normalize_path(&session.workspace); + chats.into_iter().find_map(|c| { + let cwd = c.cwd.as_deref()?; + if normalize_path(Path::new(cwd)) != ws { + return None; + } + let title = c.title.trim(); + if title.is_empty() || title == "Untitled" { + None + } else { + Some(title.to_string()) + } + }) +} + +fn normalize_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn title_from_terminal(backend: &mut TerminalBackend) -> Option { + let content = backend.sync(); + let mut lines: Vec = Vec::new(); + let mut current_line: Option = None; + let mut buf = String::new(); + + for indexed in content.grid.display_iter() { + let line = indexed.point.line.0; + if current_line != Some(line) { + if current_line.is_some() { + push_terminal_line(&mut lines, &buf); + } + buf.clear(); + current_line = Some(line); + } + if indexed.c != '\0' { + buf.push(indexed.c); + } + } + if current_line.is_some() { + push_terminal_line(&mut lines, &buf); + } + + lines + .into_iter() + .find_map(|line| sanitize_auto_title(&line)) +} + +fn push_terminal_line(lines: &mut Vec, buf: &str) { + let trimmed = buf.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } +} + +fn sanitize_auto_title(line: &str) -> Option { + let mut s = line.trim().to_string(); + // Strip common TUI bullets / prompt chrome. + for prefix in ["› ", "> ", "❯ ", "• ", "- ", "* ", "$ "] { + if let Some(rest) = s.strip_prefix(prefix) { + s = rest.trim().to_string(); + break; + } + } + // Skip tiny / decorative noise. + let alnum = s.chars().filter(|c| c.is_alphanumeric()).count(); + if alnum < 4 || s.len() < 4 { + return None; + } + if s.chars().all(|c| !c.is_alphanumeric()) { + return None; + } + const MAX: usize = 48; + if s.chars().count() > MAX { + let truncated: String = s.chars().take(MAX.saturating_sub(1)).collect(); + Some(format!("{truncated}…")) + } else { + Some(s) + } } -/// `CURSOR_AGENT` env, else `cursor-agent` / `agent` on PATH. +/// `CURSOR_AGENT` env, else `cursor-agent` on PATH. +/// +/// Does not fall through to bare `agent` — that name is often another CLI (e.g. grok). pub fn resolve_agent_binary() -> Result { if let Ok(path) = std::env::var("CURSOR_AGENT") { let path = path.trim(); @@ -96,17 +302,113 @@ pub fn resolve_agent_binary() -> Result { } } - for name in ["cursor-agent", "agent"] { - if which(name).is_some() { - return Ok(name.to_string()); - } + if which("cursor-agent").is_some() { + return Ok("cursor-agent".into()); } Err( - "cursor-agent not found (set CURSOR_AGENT or install agent on PATH)".into(), + "cursor-agent not found (set CURSOR_AGENT or install cursor-agent on PATH)".into(), ) } +/// Scan `~/.cursor/chats` for resumable sessions (newest first). +pub fn list_saved_chats() -> Result, String> { + let root = chats_root().ok_or_else(|| "HOME is unset; cannot find ~/.cursor/chats".to_string())?; + if !root.is_dir() { + return Ok(Vec::new()); + } + + let mut chats = Vec::new(); + let workspace_dirs = fs::read_dir(&root).map_err(|e| format!("read {}: {e}", root.display()))?; + for ws_entry in workspace_dirs.flatten() { + let ws_path = ws_entry.path(); + if !ws_path.is_dir() { + continue; + } + let chat_dirs = match fs::read_dir(&ws_path) { + Ok(d) => d, + Err(_) => continue, + }; + for chat_entry in chat_dirs.flatten() { + let chat_path = chat_entry.path(); + if !chat_path.is_dir() { + continue; + } + let meta_path = chat_path.join("meta.json"); + if !meta_path.is_file() { + continue; + } + let Some(chat) = parse_saved_chat(&chat_path, &meta_path) else { + continue; + }; + chats.push(chat); + } + } + + chats.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); + Ok(chats) +} + +fn chats_root() -> Option { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".cursor").join("chats")) +} + +fn parse_saved_chat(chat_path: &Path, meta_path: &Path) -> Option { + let text = fs::read_to_string(meta_path).ok()?; + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + + // Empty shells (created but never prompted) are not useful to resume. + if value.get("hasConversation").and_then(|v| v.as_bool()) != Some(true) { + return None; + } + + let id = chat_path.file_name()?.to_str()?.to_string(); + let title = value + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("Untitled") + .to_string(); + let cwd = value + .get("cwd") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let updated_at_ms = value + .get("updatedAtMs") + .and_then(|v| v.as_u64()) + .or_else(|| value.get("createdAtMs").and_then(|v| v.as_u64())) + .unwrap_or(0); + + Some(SavedChat { + id, + title, + cwd, + updated_at_ms, + }) +} + +fn format_age(updated_at_ms: u64) -> String { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let age = Duration::from_millis(now_ms.saturating_sub(updated_at_ms)); + let secs = age.as_secs(); + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} + fn which(name: &str) -> Option { let output = Command::new("which").arg(name).output().ok()?; if !output.status.success() { @@ -139,9 +441,15 @@ fn build_args(draft: &NewSessionDraft, workspace: &Path) -> Vec { args.push(model.to_string()); } - let prompt = draft.prompt.trim(); - if !prompt.is_empty() { - args.push(prompt.to_string()); + if let Some(chat_id) = draft.resume_chat_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) + { + args.push("--resume".into()); + args.push(chat_id.to_string()); + } else { + let prompt = draft.prompt.trim(); + if !prompt.is_empty() { + args.push(prompt.to_string()); + } } args diff --git a/src/subagents.rs b/src/subagents.rs new file mode 100644 index 0000000..e438b7f --- /dev/null +++ b/src/subagents.rs @@ -0,0 +1,448 @@ +//! Discover Cursor Task/subagents for a bound chat id under `~/.cursor/chats`. + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// Running or finished subagent/Task under a parent chat. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Subagent { + pub id: String, + pub title: String, + pub kind: Option, + pub status: SubagentStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubagentStatus { + Running, + Done, + Failed, +} + +impl SubagentStatus { + pub fn label(self) -> &'static str { + match self { + Self::Running => "running", + Self::Done => "done", + Self::Failed => "failed", + } + } + + pub fn is_live(self) -> bool { + matches!(self, Self::Running) + } +} + +/// Snapshot of chat meta + nested subagents for one chat id. +#[derive(Debug, Clone, Default)] +pub struct ChatSnapshot { + pub title: Option, + pub updated_at_ms: Option, + pub subagents: Vec, +} + +/// Locate `~/.cursor/chats/*//` by scanning workspace-hash dirs. +pub fn find_chat_dir(chat_id: &str) -> Option { + let chat_id = chat_id.trim(); + if chat_id.is_empty() { + return None; + } + let root = chats_root()?; + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let ws = entry.path(); + if !ws.is_dir() { + continue; + } + let candidate = ws.join(chat_id); + if candidate.is_dir() { + return Some(candidate); + } + } + None +} + +/// Poll meta + subagents/ + Task blobs for a chat id. +pub fn poll_chat(chat_id: &str) -> ChatSnapshot { + let Some(dir) = find_chat_dir(chat_id) else { + return ChatSnapshot::default(); + }; + + let mut snap = ChatSnapshot::default(); + if let Some((title, updated)) = read_meta(&dir) { + snap.title = title; + snap.updated_at_ms = updated; + } + + let mut by_id: std::collections::BTreeMap = std::collections::BTreeMap::new(); + + for sub in list_subagents_dir(&dir) { + by_id.insert(sub.id.clone(), sub); + } + for sub in scan_task_blobs(&dir) { + by_id + .entry(sub.id.clone()) + .and_modify(|existing| merge_subagent(existing, &sub)) + .or_insert(sub); + } + + snap.subagents = by_id.into_values().collect(); + snap.subagents.sort_by(|a, b| a.title.cmp(&b.title).then(a.id.cmp(&b.id))); + snap +} + +fn chats_root() -> Option { + let home = std::env::var_os("HOME")?; + Some(PathBuf::from(home).join(".cursor").join("chats")) +} + +fn read_meta(chat_dir: &Path) -> Option<(Option, Option)> { + let text = fs::read_to_string(chat_dir.join("meta.json")).ok()?; + let value: Value = serde_json::from_str(&text).ok()?; + let title = value + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "Untitled") + .map(str::to_string); + let updated = value + .get("updatedAtMs") + .and_then(|v| v.as_u64()) + .or_else(|| value.get("createdAtMs").and_then(|v| v.as_u64())); + Some((title, updated)) +} + +fn list_subagents_dir(chat_dir: &Path) -> Vec { + let dir = chat_dir.join("subagents"); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + // Skip sqlite sidecars / hidden junk. + if name.starts_with('.') || name.ends_with("-shm") || name.ends_with("-wal") { + continue; + } + + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(name) + .to_string(); + if stem.is_empty() { + continue; + } + + let (title, kind, status) = inspect_subagent_file(&path, &stem); + out.push(Subagent { + id: stem, + title, + kind, + status, + }); + } + out +} + +fn inspect_subagent_file(path: &Path, stem: &str) -> (String, Option, SubagentStatus) { + let mut title = stem.to_string(); + let mut kind = None; + let mut status = SubagentStatus::Running; + + // Prefer a sibling .json meta if present. + let meta_path = path.with_extension("json"); + if meta_path.is_file() { + if let Ok(text) = fs::read_to_string(&meta_path) { + if let Ok(value) = serde_json::from_str::(&text) { + if let Some(t) = value + .get("title") + .or_else(|| value.get("description")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + title = t.to_string(); + } + if let Some(k) = value + .get("subagentType") + .or_else(|| value.get("type")) + .or_else(|| value.get("kind")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + kind = Some(k.to_string()); + } + if let Some(s) = value.get("status").and_then(|v| v.as_str()) { + status = parse_status(s); + } + } + } + } + + // jsonl / store-like files: skim first/last chunks for title + completion. + if let Ok(bytes) = fs::read(path) { + let text = String::from_utf8_lossy(&bytes); + if title == stem { + if let Some(t) = extract_description_from_text(&text) { + title = t; + } + } + if kind.is_none() { + kind = extract_kind_from_text(&text); + } + if text_indicates_done(&text) { + status = SubagentStatus::Done; + } else if text_indicates_failed(&text) { + status = SubagentStatus::Failed; + } else if is_stale_file(path) && status == SubagentStatus::Running { + // Quiet files that haven't grown recently are likely finished. + status = SubagentStatus::Done; + } + } + + (truncate(&title, 48), kind, status) +} + +fn scan_task_blobs(chat_dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for name in ["store.db", "store.db-wal"] { + let path = chat_dir.join(name); + let Ok(bytes) = fs::read(&path) else { + continue; + }; + for sub in extract_tasks_from_bytes(&bytes) { + if seen.insert(sub.id.clone()) { + out.push(sub); + } + } + } + out +} + +fn extract_tasks_from_bytes(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + let mut out = Vec::new(); + + // IDE-style JSON tool calls. + for (idx, _) in text.match_indices("\"toolName\":\"Task\"") { + let window = surrounding(&text, idx, 1200); + let id = extract_json_string(window, "toolCallId") + .or_else(|| extract_json_string(window, "agentId")) + .unwrap_or_else(|| format!("task-{idx}")); + let title = extract_json_string(window, "description") + .or_else(|| extract_json_string(window, "title")) + .unwrap_or_else(|| "Task".into()); + let kind = extract_json_string(window, "subagentType") + .or_else(|| extract_json_string(window, "subagent_type")); + let status = if window.contains("\"status\":\"completed\"") + || window.contains("\"subtype\":\"completed\"") + || window.contains("tool-result") + { + SubagentStatus::Done + } else if window.contains("\"status\":\"error\"") + || window.contains("\"status\":\"failed\"") + || window.contains("\"status\":\"aborted\"") + { + SubagentStatus::Failed + } else { + SubagentStatus::Running + }; + out.push(Subagent { + id, + title: truncate(&title, 48), + kind, + status, + }); + } + + // stream-json / protobuf-ish readable fragments. + for (idx, _) in text.match_indices("taskToolCall") { + let window = surrounding(&text, idx, 800); + let id = extract_json_string(window, "call_id") + .or_else(|| extract_json_string(window, "toolCallId")) + .unwrap_or_else(|| format!("taskToolCall-{idx}")); + let title = extract_json_string(window, "description") + .or_else(|| extract_json_string(window, "prompt")) + .unwrap_or_else(|| "Subagent task".into()); + let kind = extract_json_string(window, "subagentType"); + let status = if window.contains("\"subtype\":\"completed\"") + || window.contains("\"case\":\"success\"") + { + SubagentStatus::Done + } else if window.contains("\"case\":\"error\"") || window.contains("rejected") { + SubagentStatus::Failed + } else { + SubagentStatus::Running + }; + out.push(Subagent { + id, + title: truncate(&title, 48), + kind, + status, + }); + } + + out +} + +fn surrounding<'a>(text: &'a str, idx: usize, radius: usize) -> &'a str { + let start = idx.saturating_sub(radius); + let end = (idx + radius).min(text.len()); + // Stay on char boundaries. + let start = text + .char_indices() + .find(|(i, _)| *i >= start) + .map(|(i, _)| i) + .unwrap_or(0); + let end = text + .char_indices() + .rev() + .find(|(i, _)| *i <= end) + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(text.len()); + &text[start..end] +} + +fn extract_json_string(window: &str, key: &str) -> Option { + let pattern = format!("\"{key}\":\""); + let start = window.find(&pattern)? + pattern.len(); + let rest = &window[start..]; + let mut out = String::new(); + let mut chars = rest.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(n) = chars.next() { + out.push(match n { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '"' => '"', + '\\' => '\\', + other => other, + }); + } + continue; + } + if c == '"' { + break; + } + out.push(c); + if out.len() > 200 { + break; + } + } + let trimmed = out.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn extract_description_from_text(text: &str) -> Option { + extract_json_string(text, "description") + .or_else(|| extract_json_string(text, "title")) + .or_else(|| extract_json_string(text, "prompt")) +} + +fn extract_kind_from_text(text: &str) -> Option { + extract_json_string(text, "subagentType") + .or_else(|| extract_json_string(text, "subagent_type")) + .or_else(|| extract_json_string(text, "type")) +} + +fn text_indicates_done(text: &str) -> bool { + text.contains("\"status\":\"completed\"") + || text.contains("\"subtype\":\"completed\"") + || text.contains("turn_ended") + || text.contains("\"type\":\"result\"") +} + +fn text_indicates_failed(text: &str) -> bool { + text.contains("\"status\":\"error\"") + || text.contains("\"status\":\"failed\"") + || text.contains("\"status\":\"aborted\"") +} + +fn parse_status(s: &str) -> SubagentStatus { + match s.trim().to_ascii_lowercase().as_str() { + "running" | "in_progress" | "started" => SubagentStatus::Running, + "error" | "failed" | "aborted" | "cancelled" => SubagentStatus::Failed, + _ => SubagentStatus::Done, + } +} + +fn is_stale_file(path: &Path) -> bool { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + let Ok(modified) = meta.modified() else { + return false; + }; + let Ok(age) = SystemTime::now().duration_since(modified) else { + return false; + }; + age.as_secs() > 90 +} + +fn merge_subagent(existing: &mut Subagent, incoming: &Subagent) { + if existing.title == existing.id && incoming.title != incoming.id { + existing.title = incoming.title.clone(); + } + if existing.kind.is_none() { + existing.kind = incoming.kind.clone(); + } + // Prefer terminal statuses from either side. + existing.status = match (existing.status, incoming.status) { + (SubagentStatus::Failed, _) | (_, SubagentStatus::Failed) => SubagentStatus::Failed, + (SubagentStatus::Done, _) | (_, SubagentStatus::Done) => SubagentStatus::Done, + _ => SubagentStatus::Running, + }; +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let t: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{t}…") + } +} + +/// Create an empty chat and return its id (stdout from `cursor-agent create-chat`). +pub fn create_chat(agent_bin: &str, workspace: &Path) -> Result { + let output = std::process::Command::new(agent_bin) + .arg("create-chat") + .current_dir(workspace) + .output() + .map_err(|e| format!("failed to run create-chat: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "create-chat failed ({}): {}", + output.status, + stderr.trim() + )); + } + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { + return Err("create-chat returned empty chat id".into()); + } + // Basic UUID-ish sanity (cursor returns hyphenated ids). + if id.len() < 8 || id.contains(char::is_whitespace) { + return Err(format!("create-chat returned unexpected id: {id}")); + } + Ok(id) +}