diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feca909..231e3c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,3 +16,4 @@ jobs: - run: cargo fmt --check - run: cargo clippy --all-targets -- -D warnings - run: cargo test --lib + - run: cargo test --test sftp_prompt diff --git a/Cargo.lock b/Cargo.lock index 0937613..7ae8b62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,6 +1098,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "delegate" version = "0.13.5" @@ -1426,6 +1432,7 @@ dependencies = [ "itertools", "log", "profiling", + "resvg", "syntect", ] @@ -1751,6 +1758,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -2390,12 +2403,18 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.1", "tiff", "zune-core", "zune-jpeg", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.14.2" @@ -2588,6 +2607,17 @@ dependencies = [ "accesskit_consumer 0.35.0", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3495,7 +3525,7 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "bytemuck", "color", - "kurbo", + "kurbo 0.13.1", "linebender_resource_handle", "smallvec", ] @@ -3559,6 +3589,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3660,6 +3696,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "png" version = "0.18.1" @@ -4034,6 +4083,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + [[package]] name = "rfc6979" version = "0.6.0" @@ -4071,6 +4134,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -4085,6 +4157,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -4611,6 +4689,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.4" @@ -4834,6 +4921,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "subtle" @@ -4841,6 +4931,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "syn" version = "2.0.119" @@ -4947,6 +5047,7 @@ name = "tether" version = "0.2.2" dependencies = [ "anyhow", + "arboard", "directories", "eframe", "egui_dock", @@ -4976,6 +5077,7 @@ dependencies = [ "ureq", "uuid", "vt100", + "vte", "windows-sys 0.61.2", "winreg 0.56.0", "zeroize", @@ -5076,6 +5178,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -5333,6 +5436,28 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6455,6 +6580,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 53b3832..5662ee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ publish = false anyhow = "1.0.104" directories = "6.0.0" eframe = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["syntect"] } +egui_extras = { version = "0.36", default-features = false, features = ["syntect", "svg"] } egui_dock = { version = "0.21.1", features = ["serde"] } futures = "0.3" keyring = { version = "3", features = ["windows-native"] } @@ -31,6 +31,9 @@ tokio = { version = "1.53.3", features = ["full"] } ureq = { version = "2.12", default-features = false, features = ["tls", "json"] } uuid = { version = "1.28.0", features = ["v4", "serde"] } vt100 = "0.16.2" +vte = "0.15" +arboard = "3.7" +tempfile = "3" zeroize = "1" portable-pty = "0.9" serde_norway = "0.9.42" @@ -41,7 +44,6 @@ notify-rust = "4" toml_edit = "0.25" [dev-dependencies] -tempfile = "3" egui_kittest = { version = "0.36", features = ["eframe", "wgpu", "snapshot"] } [profile.release] diff --git a/README.md b/README.md index 731f232..b3a2b4c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,23 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B - **Secure by default**: secrets live in the OS credential store, host keys are trusted on first use, and tether refuses to connect if a host key changes. - **Customisable**: themes, and every shortcut can be rebound (*Settings → Keyboard*). +### Terminal and agent shortcuts + +- **Shift+Enter** inserts a line without submitting in Claude Code, Codex and integrated PowerShell, Bash, Zsh and Fish prompts. Enter submits normally. +- **Images**: a local terminal running a detected Claude Code or Codex session shows **+ Image** and accepts screenshot / PNG / JPEG attachments. Ordinary shells use the full terminal height and do not show the image toolbar. Colored **[Image 1]** labels show a thumbnail on hover; Tether inserts a usable file path for the agent. Local temporary copies are removed when the terminal closes. **Clear previews** only hides the thumbnails; edit the prompt in the terminal to remove an inserted path. +- **Claude Code / Codex**: Codex has its own movable sidebar view with terminal sessions, activity, attention badges, CPU / memory, last messages and navigation, using the same monitoring system as Claude. Configure Claude hooks and Codex completion notifications in **Settings → AI agents**. Codex `notify` reports completed turns; ongoing work is tracked from terminal activity and processes. +- **Custom Boot**: right-click either agent icon, enter your local command and choose its shell. Tether passes the command and flags through unchanged, including account selectors implemented by your own functions or aliases. Choose the shell whose startup profile defines them (Windows PowerShell and PowerShell 7 use different profiles). The shell is remembered separately for each agent. +- **Project launchers**: both normal launch and Custom Boot ask for a local project directory in the same card. Type a path (including `~`), use Tab / Shift+Tab completion or browse. Give it a name and choose **☆ Save** to pin a shortcut shared by Claude and Codex. Local terminal folders are remembered automatically, with up to 32 recent folders; right-click a shortcut to pin, unpin or forget it. +- **Terminal colors**: local PTYs and SSH sessions share `xterm-256color` / true-color capability hints. Local shells do not inherit a GUI runner's `NO_COLOR`; ANSI, indexed and RGB colors use the existing terminal renderer and theme palette. SSH servers may restrict optional environment hints through `AcceptEnv`; their PTY still advertises 256 colors. +- **Remote path links**: click a remote path to open SFTP beside that terminal, using the same companion pane as **Ctrl+Shift+F**; right-click also offers **Open in SFTP**, the editor and copy options. Repeated clicks navigate the existing pane. Paths inside prompts such as `prisma@host:~/project$` retain the prompt's account: `~` resolves to prisma's actual home on the server, even when SSH logged in as root and the shell used `su` / `sudo`. No `/home/` assumption is made. Relative paths resolve against the source terminal's directory; files open their containing folder. +- **Tab close targets**: hovering the close button or its surrounding area keeps the tab's current expansion, so resource details do not move the X while you aim for it. + +### Share machine setups and find SSH keys + +Open **Import / export setup…** from the app or Hosts menu. **Export setup** previews the machines and explains that anyone with the JSON file can import their names, IP addresses / hostnames, ports, usernames and colors. Keys, key paths, passwords and passphrases are excluded. Choose the **Tether setup** source card under **Import**, review the selected machines and import them; existing endpoints are skipped and imported machines need credentials before connecting. + +**Search this PC** in Import finds Bitvise profiles in common OS-provided folders. **Search this PC for SSH keys…** in the key manager or host editor searches `.ssh`, Bitvise locations, Documents, Desktop and Downloads, and displays each result's full path. Searches run in the background with a depth/time/file limit and skip symbolic links. You choose what to import; **Browse…** handles other locations. Export keys held in Bitvise's encrypted internal store as OpenSSH files from Bitvise before importing them. + ## Install Download `Tether-Setup-.exe` from [Releases](https://github.com/izLoki/Tether/releases) and run it. No admin rights needed. It adds Start menu and desktop shortcuts. Tether checks for new releases on startup and installs them in one click (*Settings → About → Updates*). Running a newer installer also upgrades in place. diff --git a/assets/agents/README.md b/assets/agents/README.md new file mode 100644 index 0000000..b566386 --- /dev/null +++ b/assets/agents/README.md @@ -0,0 +1 @@ +The Codex mark is the OpenAI blossom used by the official Codex desktop app. Source: Codex for Windows 26.1013.4894.0, webview/assets/logo-18892d25d393.svg. The geometry is unchanged; the fill is white so the UI can tint it for light and dark themes. OpenAI retains its rights to the mark, which identifies the integrated product. Brand guidelines: https://openai.com/brand/ diff --git a/assets/agents/codex.svg b/assets/agents/codex.svg new file mode 100644 index 0000000..6d88c64 --- /dev/null +++ b/assets/agents/codex.svg @@ -0,0 +1 @@ + diff --git a/src/actions.rs b/src/actions.rs index 22500a9..0be03c2 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -39,8 +39,11 @@ pub enum Command { OpenLayoutSettings, /// The "Import hosts" dialog (Bitvise, SSH config). ImportHosts, - /// Start an AI agent CLI in a new default shell. - LaunchAgent { kind: crate::agents::AgentKind, command: &'static str, pick_folder: bool }, + /// Choose a local project and shell, then start an AI agent CLI. + LaunchAgent { + kind: crate::agents::AgentKind, + command: &'static str, + }, /// Anything else: runs on the UI thread when chosen. Run(Arc), } @@ -207,7 +210,8 @@ fn action_keywords(a: Action) -> &'static str { Action::NewLocalTerminal => "shell console", Action::ToggleBroadcast => "all terminals sync", Action::NextAttention => "agent waiting jump", - Action::ToggleAgents => "ai claude codex", + Action::ToggleAgents => "ai claude", + Action::ToggleCodex => "ai codex openai", _ => "", } } @@ -241,7 +245,8 @@ pub fn fuzzy_match(needle: &str, hay: &str) -> Option<(i32, Vec)> { let lower_str: String = lower.iter().collect(); let substring_at = lower_str.find(&needle_str).map(|byte| lower_str[..byte].chars().count()); - let word_start = |i: usize| i == 0 || matches!(h[i - 1], ' ' | '-' | '_' | '/' | '\\' | '.' | ':' | '·' | '(') || (h[i - 1].is_lowercase() && h[i].is_uppercase()); + let word_start = + |i: usize| i == 0 || matches!(h[i - 1], ' ' | '-' | '_' | '/' | '\\' | '.' | ':' | '·' | '(') || (h[i - 1].is_lowercase() && h[i].is_uppercase()); // Greedy forward pass, preferring word starts: for each needle char take the next // word-start occurrence if one comes before the next plain occurrence's run ends. diff --git a/src/agents/hub.rs b/src/agents/hub.rs index cac1795..e0666e1 100644 --- a/src/agents/hub.rs +++ b/src/agents/hub.rs @@ -208,14 +208,7 @@ pub struct AgentHub { impl AgentHub { /// Starts the hook pipe and the resource monitor. pub fn new(rt: &tokio::runtime::Handle, ctx: &egui::Context) -> Self { - Self { - sessions: HashMap::new(), - inbox: ipc::start(rt, ctx), - monitor: Arc::new(Monitor::start(ctx)), - listed: Vec::new(), - listed_key: 0, - badges_key: 0, - } + Self { sessions: HashMap::new(), inbox: ipc::start(rt, ctx), monitor: Arc::new(Monitor::start(ctx)), listed: Vec::new(), listed_key: 0, badges_key: 0 } } pub fn monitor(&self) -> &Monitor { @@ -315,11 +308,7 @@ impl AgentHub { session.ended = true; transitions.extend(session.tracker.apply(Event::Exited(code), now)); } - let obs = Observation { - title_busy: title_activity(Some(kind), &session.seen_title), - last_output: seen.last_output, - subprocs_busy, - }; + let obs = Observation { title_busy: title_activity(Some(kind), &session.seen_title), last_output: seen.last_output, subprocs_busy }; transitions.extend(session.tracker.observe(obs, now)); transitions.extend(settle_done(session, focused, now)); for (_, to) in transitions { @@ -569,6 +558,24 @@ mod tests { assert_eq!(alerts.len(), 1, "rate limited, and work is not an alert"); } + #[test] + fn codex_notify_updates_its_session_and_alert_without_touching_claude() { + let now = Instant::now(); + let mut codex = Session::new("codex-tag".into(), 0, 0, now); + let mut claude = Session::new("claude-tag".into(), 0, 0, now); + apply_hook(&mut claude, &msg("UserPromptSubmit", |_| {}), now); + let payload = serde_json::json!({"type":"agent-turn-complete", "thread-id":"thread-1", "cwd":"D:/work/api-server", "last-assistant-message":"Completed the refactor"}); + let message = super::super::hook::build_message("codex", None, &payload, "codex-tag").unwrap(); + let to = apply_hook(&mut codex, &message, now).unwrap(); + assert_eq!(codex.kind, Some(AgentKind::Codex)); + assert_eq!(codex.project(), "api-server"); + assert_eq!(codex.tracker.message.as_deref(), Some("Completed the refactor")); + assert_eq!(claude.tracker.state, AgentState::Working); + let mut alerts = Vec::new(); + push_alert(&mut alerts, 42, &mut codex, to, now); + assert_eq!((alerts[0].uid, alerts[0].kind, alerts[0].state), (42, AgentKind::Codex, AgentState::Done)); + } + #[test] fn agent_kind_follows_the_process_tree() { let t0 = Instant::now(); diff --git a/src/agents/install.rs b/src/agents/install.rs index 67ce16d..4d4dc65 100644 --- a/src/agents/install.rs +++ b/src/agents/install.rs @@ -350,7 +350,8 @@ pub fn apply(plan: &Plan) -> Result, String> { } // Replace the file a symlink points to, not the link (dotfile managers link these). let is_link = std::fs::symlink_metadata(&plan.path).is_ok_and(|m| m.file_type().is_symlink()); - let target = if is_link { std::fs::canonicalize(&plan.path).map_err(|e| format!("cannot follow {}: {e}", plan.path.display()))? } else { plan.path.clone() }; + let target = + if is_link { std::fs::canonicalize(&plan.path).map_err(|e| format!("cannot follow {}: {e}", plan.path.display()))? } else { plan.path.clone() }; let mut backup = None; if let Some(old) = &now { let bak = backup_path(&target); diff --git a/src/agents/state.rs b/src/agents/state.rs index 700f191..db8b33b 100644 --- a/src/agents/state.rs +++ b/src/agents/state.rs @@ -140,8 +140,10 @@ pub struct Tracker { pub state: AgentState, /// When `state` was entered. pub since: Instant, - /// Hook events have arrived for this session (activity heuristics are then off). + /// Hook events have arrived for this session, including completion-only notify. pub hooked: bool, + /// Full lifecycle hooks suppress heuristics; Codex notify only reports completion. + lifecycle_hooks: bool, /// Last notification / prompt / message, for the agents panel. pub message: Option, /// `message` came from terminal output (OSC 9/777/99), not from the agent's hooks. @@ -166,6 +168,7 @@ impl Tracker { state: AgentState::Idle, since: now, hooked: false, + lifecycle_hooks: false, message: None, message_from_terminal: false, last_input: None, @@ -201,9 +204,7 @@ impl Tracker { // Child scripts and shells often ring `\x07` while Claude is working; never // let a bell interrupt a hooked turn, an active spinner, or running subprocesses. AgentState::Idle if !self.subprocs_busy && self.active_subagents == 0 => self.set(AgentState::NeedsInput, now), - AgentState::Working - if !self.hooked && !self.subprocs_busy && self.active_subagents == 0 && self.prev_title_busy != Some(true) => - { + AgentState::Working if !self.hooked && !self.subprocs_busy && self.active_subagents == 0 && self.prev_title_busy != Some(true) => { self.set(AgentState::NeedsInput, now) } _ => None, @@ -230,6 +231,11 @@ impl Tracker { } Event::Hook { kind, message } => { self.hooked = !matches!(kind, HookKind::SessionEnd); + match kind { + HookKind::SessionEnd => self.lifecycle_hooks = false, + HookKind::TurnComplete => {} + _ => self.lifecycle_hooks = true, + } if let Some(m) = message.filter(|m| !m.trim().is_empty()) { self.message = Some(m); self.message_from_terminal = false; @@ -265,11 +271,7 @@ impl Tracker { self.active_subagents = self.active_subagents.saturating_sub(1); // If a subagent finished while the session was mistakenly marked Done or NeedsInput, // parent Claude is now continuing its turn. - if matches!(self.state, AgentState::Done | AgentState::NeedsInput) { - self.set(AgentState::Working, now) - } else { - None - } + if matches!(self.state, AgentState::Done | AgentState::NeedsInput) { self.set(AgentState::Working, now) } else { None } } } } @@ -322,7 +324,7 @@ impl Tracker { Some(false) if prev_busy == Some(true) && self.state == AgentState::Working - && !self.hooked + && !self.lifecycle_hooks && !obs.subprocs_busy && self.active_subagents == 0 => { @@ -340,7 +342,7 @@ impl Tracker { return self.set(AgentState::Done, now); } // The title speaks for programs that set one; hooks speak for hooked sessions. - if self.hooked || obs.title_busy == Some(true) { + if self.lifecycle_hooks || obs.title_busy == Some(true) { return None; } if obs.title_busy.is_some() && !self.output_is_work(now) { @@ -372,7 +374,7 @@ impl Tracker { /// How soon `observe` should run again even if nothing is printed (a silence can /// end a turn). `None` when only new output or events can change anything. pub fn recheck_in(&self, now: Instant) -> Option { - if self.hooked && !self.by_subprocs { + if self.lifecycle_hooks && !self.by_subprocs { return None; } match self.state { @@ -421,6 +423,25 @@ mod tests { t.observe(Observation { title_busy: None, last_output: last, subprocs_busy: false }, start + ms(at)) } + #[test] + fn codex_completion_does_not_disable_tracking_the_next_turn() { + let start = Instant::now(); + let mut t = Tracker::new(start); + stream(&mut t, start, 0, 1500); + assert_eq!(t.state, AgentState::Working); + t.apply(Event::Hook { kind: HookKind::TurnComplete, message: Some("First answer".into()) }, start + ms(1600)); + assert!(t.hooked); + assert_eq!(t.state, AgentState::Done); + t.apply(Event::Input, start + ms(4000)); + assert_eq!(t.state, AgentState::Idle); + assert_eq!(stream(&mut t, start, 6000, 7500), vec![(AgentState::Idle, AgentState::Working)]); + t.apply(Event::Hook { kind: HookKind::TurnComplete, message: Some("Second answer".into()) }, start + ms(7600)); + assert_eq!(t.state, AgentState::Done); + assert_eq!(t.message.as_deref(), Some("Second answer")); + t.apply(Event::Notify("Approval needed: allow command?".into()), start + ms(9000)); + assert_eq!(t.state, AgentState::NeedsPermission); + } + #[test] fn sustained_output_is_work_and_silence_finishes_it() { let t0 = Instant::now(); diff --git a/src/agents/title.rs b/src/agents/title.rs index 302f5d9..130b602 100644 --- a/src/agents/title.rs +++ b/src/agents/title.rs @@ -53,7 +53,13 @@ pub fn session_title(kind: AgentKind, raw_title: Option<&str>, cwd: Option<&str> /// The title a local terminal tab shows. In order: the user's name for the tab, the /// agent session's name while an agent runs, else what `shell` makes of the terminal /// title (a stale agent title is dropped, so the tab reverts once the agent quits). -pub fn tab_title(custom: Option<&str>, agent: Option, raw_title: Option<&str>, cwd: Option<&str>, shell: impl FnOnce(Option<&str>) -> String) -> String { +pub fn tab_title( + custom: Option<&str>, + agent: Option, + raw_title: Option<&str>, + cwd: Option<&str>, + shell: impl FnOnce(Option<&str>) -> String, +) -> String { if let Some(name) = custom.map(str::trim).filter(|n| !n.is_empty()) { return name.to_string(); } diff --git a/src/app.rs b/src/app.rs index ac19114..b74bfc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,10 +19,10 @@ mod chrome; mod layout_ui; #[path = "app_local.rs"] mod local; -#[path = "app_views.rs"] -mod views_ui; #[path = "app_palette.rs"] mod palette_ui; +#[path = "app_views.rs"] +mod views_ui; use crate::folders::FolderStore; use crate::keybindings::{self, Action}; @@ -37,14 +37,14 @@ use crate::ui::detach::Detacher; use crate::ui::editor::ProfileEditor; use crate::ui::import_view::{ImportDialog, ImportOutcome}; use crate::ui::key_manager::KeyManager; +use crate::ui::palette::Palette; use crate::ui::settings_view::SettingsView; use crate::ui::sftp_view::SftpView; -use crate::ui::palette::Palette; use crate::ui::sidebar::{OpenKind, Sidebar, WorkspaceInfo}; use crate::ui::tabs::{Tab, TabBody, Viewer}; -use crate::ui::vertical_tabs::VerticalTabs; use crate::ui::terminal_view::TerminalView; use crate::ui::toast::Toasts; +use crate::ui::vertical_tabs::VerticalTabs; use crate::workspace::{self, LAST_SESSION, TabKind, Workspaces}; /// Password / passphrase asked before connecting (nothing stored, no live connection). @@ -87,6 +87,8 @@ pub struct TetherApp { broadcast: bool, editor: Option, import: Option, + custom_boot: Option, + project_cwds: std::collections::HashMap, settings_view: Option, key_manager: Option, prompt: Option, @@ -176,6 +178,8 @@ impl TetherApp { detached: Detacher::default(), mini_dock: Default::default(), agents, + custom_boot: None, + project_cwds: Default::default(), }) } @@ -212,10 +216,7 @@ impl TetherApp { } OpenKind::Sftp => { let can_attach = self.dock.find_active_focused().is_some_and(|(_, active)| { - !active.is_local() - && active.profile.id == profile.id - && matches!(active.body, TabBody::Terminal(_)) - && active.linked_sftp.is_none() + !active.is_local() && active.profile.id == profile.id && matches!(active.body, TabBody::Terminal(_)) && active.linked_sftp.is_none() }); if can_attach { let view = self.spawn_sftp(&profile, secret, ctx); @@ -246,6 +247,7 @@ impl TetherApp { } fn spawn_terminal(&self, profile: &Profile, secret: Option, ctx: &egui::Context) -> TerminalView { + let image_target = crate::ui::attachments::RemoteTarget { pool: self.pool.clone(), profile: profile.clone(), runtime: self.rt.handle().clone() }; let (handle, rx) = TerminalHandle::new(24, 80); // Wakes the UI even if this tab is never drawn (restored workspace, background tab). handle.repaint.attach(ctx); @@ -275,7 +277,9 @@ impl TetherApp { } ctx.request_repaint(); }); - TerminalView::new(handle) + let mut view = TerminalView::new(handle); + view.attachments().remote = Some(image_target); + view } fn terminal_tab(&self, profile: &Profile, secret: Option, ctx: &egui::Context) -> Tab { @@ -305,12 +309,8 @@ impl TetherApp { tab.linked_sftp = None; return; } - let profile = self.store.get(tab.profile.id).cloned().unwrap_or_else(|| tab.profile.clone()); - let secret = secret::get(profile.secret_id()); - let view = self.spawn_sftp(&profile, secret, ctx); - if let Some((_, active)) = self.dock.find_active_focused() { - active.linked_sftp = Some(crate::ui::tabs::LinkedSftp::new(view)); - } + let uid = tab.uid; + let _ = self.ensure_linked_sftp(uid, ctx); return; } self.duplicate_focused(Split::Right, TabKind::Sftp, ctx); @@ -331,13 +331,7 @@ impl TetherApp { } let secret = secret_override.or_else(|| secret::get(profile.secret_id())); let new_view = is_terminal.then(|| self.spawn_terminal(&profile, secret.clone(), ctx)); - let Some(tab) = self - .dock - .iter_all_tabs_mut() - .map(|(_, t)| t) - .chain(self.detached.tabs_mut()) - .chain(self.mini_dock.tabs_mut()) - .find(|t| t.uid == uid) + let Some(tab) = self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == uid) else { return; }; @@ -432,6 +426,9 @@ impl TetherApp { for uid in viewer.reconnect { self.reconnect(uid, None, ctx); } + for (uid, path) in viewer.open_folders { + self.open_terminal_folder(uid, path, ctx); + } for (uid, files) in viewer.open_files { self.open_terminal_files(uid, files, ctx); } @@ -613,6 +610,9 @@ impl TetherApp { for uid in viewer.reconnect { self.reconnect(uid, None, ui.ctx()); } + for (uid, path) in viewer.open_folders { + self.open_terminal_folder(uid, path, ui.ctx()); + } for (uid, files) in viewer.open_files { self.open_terminal_files(uid, files, ui.ctx()); } @@ -657,11 +657,7 @@ impl TetherApp { /// away to another terminal hides the right-hand SFTP automatically. fn absorb_same_server_sftp_splits(&mut self) { let mut pairs: Vec<(u64, u64)> = Vec::new(); - let leaves: Vec<_> = self - .dock - .iter_leaves() - .filter(|(p, l)| p.surface.is_main() && !l.tabs.is_empty()) - .collect(); + let leaves: Vec<_> = self.dock.iter_leaves().filter(|(p, l)| p.surface.is_main() && !l.tabs.is_empty()).collect(); if leaves.len() < 2 { return; } @@ -706,6 +702,34 @@ impl TetherApp { } } + /// Both the shortcut and path links use this companion pane, owned by the source terminal. + fn ensure_linked_sftp(&mut self, uid: u64, ctx: &egui::Context) -> Option<&mut SftpView> { + let (profile, terminal, already_open) = self.all_tabs().find_map(|t| match &t.body { + TabBody::Terminal(term) if t.uid == uid && !t.is_local() => Some((t.profile.clone(), term.handle.clone(), t.linked_sftp.is_some())), + _ => None, + })?; + let new_view = if already_open { + None + } else { + let profile = self.store.get(profile.id).cloned().unwrap_or(profile); + Some(self.spawn_sftp(&profile, secret::get(profile.secret_id()), ctx)) + }; + let tab = self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == uid)?; + if let Some(view) = new_view { + tab.linked_sftp = Some(crate::ui::tabs::LinkedSftp::new(view)); + } + let view = tab.linked_sftp.as_mut()?.view.as_mut(); + view.set_linked_terminal(Some(terminal)); + Some(view) + } + + fn open_terminal_folder(&mut self, uid: u64, path: String, ctx: &egui::Context) { + if let Some(view) = self.ensure_linked_sftp(uid, ctx) { + view.browse_path(path); + ctx.request_repaint(); + } + } + /// Opens files requested from a terminal (`nano `) in the built-in editor. fn open_terminal_files(&mut self, uid: u64, files: Vec, ctx: &egui::Context) { // Local shells edit files with their own tools: only remote ones go through SFTP. @@ -745,14 +769,12 @@ impl TetherApp { } }; for file in files { - let raw_path = if file == "~" { - home.clone() - } else if let Some(rest) = file.strip_prefix("~/") { - crate::ssh::sftp::join(&home, rest) - } else if file.starts_with('/') { - file - } else { - crate::ssh::sftp::join(&cwd, &file) + let raw_path = match crate::ssh::cwd::resolve_path(&conn, &sftp, &file, &cwd).await { + Ok(path) => path, + Err(e) => { + toasts.error(format!("Could not resolve remote path: {e:#}")); + continue; + } }; let remote_path = crate::ui::path_complete::normalize_remote(&raw_path); let read = match crate::remote_edit::read_capped(&sftp, &remote_path, crate::remote_edit::MAX_EDITOR_BYTES).await { @@ -782,6 +804,7 @@ impl TetherApp { } fn modals(&mut self, ctx: &egui::Context) { + self.custom_boot_modal(ctx); self.editor_modal(ctx); self.key_manager_modal(ctx); self.import_modal(ctx); @@ -1010,4 +1033,3 @@ fn is_no_such_file(err: &anyhow::Error) -> bool { ) }) } - diff --git a/src/app_agents.rs b/src/app_agents.rs index 5e98f24..ec66286 100644 --- a/src/app_agents.rs +++ b/src/app_agents.rs @@ -23,7 +23,13 @@ fn local_terms<'a>(dock: &'a DockState, out: &mut Vec>) { for (_, leaf) in dock.iter_leaves() { for (i, tab) in leaf.tabs.iter().enumerate() { if let (TabBody::Terminal(view), true) = (&tab.body, tab.is_local()) { - out.push(TermInfo { uid: tab.uid, handle: &view.handle, visible: i == leaf.active.0, label: tab.profile.display_name(), custom: tab.custom_title.as_deref() }); + out.push(TermInfo { + uid: tab.uid, + handle: &view.handle, + visible: i == leaf.active.0, + label: tab.profile.display_name(), + custom: tab.custom_title.as_deref(), + }); } } } @@ -72,6 +78,24 @@ impl TetherApp { for dock in self.detached.docks_mut() { local_terms(dock, &mut terms); } + let mut visited = Vec::new(); + self.project_cwds.retain(|uid, _| terms.iter().any(|t| t.uid == *uid)); + for term in &terms { + let cwd = term.handle.shared.lock().ok().and_then(|s| s.parser.callbacks().cwd.clone()); + if let Some(cwd) = cwd.filter(|s| !s.is_empty() && s.len() <= 4096) + && self.project_cwds.get(&term.uid) != Some(&cwd) + { + self.project_cwds.insert(term.uid, cwd.clone()); + visited.push(cwd); + } + } + if !visited.is_empty() { + let _ = crate::settings::update(|s| { + for path in visited { + s.projects.record(&path, crate::local_projects::now()); + } + }); + } Self::command_notices(&toasts, ctx, &terms, window_focused); let alerts = self.agents.frame(ctx, terms, Instant::now()); for alert in alerts { @@ -163,7 +187,10 @@ impl TetherApp { return; } let text = RichText::new(format!("{waiting}")).size(12.0).strong().color(theme::warn()); - let tip = keybindings::hint(&format!("{waiting} agent session{} need{} you: jump to the next", if waiting == 1 { "" } else { "s" }, if waiting == 1 { "s" } else { "" }), Action::NextAttention); + let tip = keybindings::hint( + &format!("{waiting} agent session{} need{} you: jump to the next", if waiting == 1 { "" } else { "s" }, if waiting == 1 { "s" } else { "" }), + Action::NextAttention, + ); let resp = super::chrome::chrome_button(ui, Some(crate::ui::settings_widgets::Icon::Sparkle), Some(text), super::chrome::ButtonTone::Warn, &tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Agents · {waiting} need attention"))); if resp.clicked() { @@ -195,6 +222,7 @@ impl TetherApp { match action { Some(PanelAction::Focus(uid)) => self.focus_tab(uid, ctx), Some(PanelAction::Close) => self.toggle_agents(), + Some(PanelAction::CustomBoot(kind)) => self.custom_boot = Some(crate::ui::agents_panel::CustomBoot::new(kind)), Some(PanelAction::Launch(pick)) => { let target = self.dock.focused_leaf().or_else(|| self.dock.iter_leaves().next().map(|(p, _)| p)); let target = target.unwrap_or(egui_dock::NodePath { surface: egui_dock::SurfaceIndex::main(), node: egui_dock::NodeIndex::root() }); @@ -207,21 +235,52 @@ impl TetherApp { } } + pub(super) fn launch_agent_command(&mut self, kind: AgentKind, command: String, ctx: &egui::Context) { + self.apply_agents_panel(Some(PanelAction::Launch(LaunchPick { kind, command, shell_id: None, cwd: None })), ctx); + } + + pub(super) fn custom_boot_modal(&mut self, ctx: &egui::Context) { + let Some(boot) = &mut self.custom_boot else { return }; + let (command, close) = boot.show(ctx); + let target = boot.target; + if close { + self.custom_boot = None; + } + if let Some(command) = command { + if let Some(target) = target { + self.open_agents_in(vec![(target, command)], ctx); + } else { + self.apply_agents_panel(Some(PanelAction::Launch(command)), ctx); + } + } + } + /// Opens agents picked from panes' "+" menus: the default shell, then the command. pub(super) fn open_agents_in(&mut self, picks: Vec<(egui_dock::NodePath, LaunchPick)>, ctx: &egui::Context) { for (path, pick) in picks { - let Some(shell) = crate::pty::shells::default_shell() else { + if pick.cwd.is_none() { + self.custom_boot = Some(crate::ui::agents_panel::CustomBoot::launch(pick, path)); + continue; + } + let prefs = crate::settings::snapshot(); + let shell_id = pick.shell_id.as_deref().or_else(|| prefs.agents.launch_shells.get(pick.kind.id()).map(String::as_str)); + let Some(shell) = shell_id.and_then(crate::pty::shells::find).or_else(crate::pty::shells::default_shell) else { self.toasts.error("No local shell was found on this machine"); return; }; - let cwd = if pick.pick_folder { - match rfd::FileDialog::new().set_title(format!("Start {} in", pick.kind.name())).pick_folder() { - Some(dir) => Some(dir), - None => continue, + let cwd = pick.cwd; + if let Some(dir) = &cwd { + if !dir.is_dir() { + self.toasts.error("The project folder no longer exists"); + continue; } - } else { - None - }; + let _ = crate::settings::update(|s| s.projects.record(&dir.display().to_string(), crate::local_projects::now())); + } + if pick.shell_id.is_some() { + let _ = crate::settings::update(|s| { + s.agents.launch_shells.insert(pick.kind.id().into(), shell.id.clone()); + }); + } let tab = self.local_tab(shell, cwd, ctx); if let TabBody::Terminal(view) = &tab.body { // The pty buffers this until the shell reads its first line. diff --git a/src/app_chrome.rs b/src/app_chrome.rs index 6f82304..22fe90c 100644 --- a/src/app_chrome.rs +++ b/src/app_chrome.rs @@ -92,7 +92,10 @@ impl TetherApp { if !sidebar_right { sidebar_toggle(ui); } - if narrow && icon_btn(ui, Icon::Search, ButtonTone::Normal, "Command palette", &keybindings::hint("Search or run a command", Action::CommandPalette)).clicked() { + if narrow + && icon_btn(ui, Icon::Search, ButtonTone::Normal, "Command palette", &keybindings::hint("Search or run a command", Action::CommandPalette)) + .clicked() + { self.open_palette(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -134,8 +137,9 @@ impl TetherApp { self.open_palette(); } ui.separator(); - if item(ui, "Import hosts…", None) { + if item(ui, "Import / export setup…", None) { self.import = Some(ImportDialog::new(&self.store)); + ui.close(); } if item(ui, "Key manager…", Some(Action::KeyManager)) { self.run_action(Action::KeyManager, &ctx); @@ -145,6 +149,9 @@ impl TetherApp { if item(ui, "Agent sessions", Some(Action::ToggleAgents)) { self.toggle_agents(); } + if item(ui, "Codex sessions", Some(Action::ToggleCodex)) { + self.run_action(Action::ToggleCodex, &ctx); + } if item(ui, "Settings…", Some(Action::OpenSettings)) { self.run_action(Action::OpenSettings, &ctx); } @@ -180,7 +187,8 @@ impl TetherApp { if !keys.is_empty() { // A key cap, so the shortcut reads as a hint rather than as more text. let galley = p.layout_no_wrap(keys, FontId::monospace(10.5), theme::text_dim()); - let cap = Rect::from_min_size(Pos2::new(rect.right() - 6.0 - galley.size().x - 12.0, rect.center().y - 9.0), Vec2::new(galley.size().x + 12.0, 18.0)); + let cap = + Rect::from_min_size(Pos2::new(rect.right() - 6.0 - galley.size().x - 12.0, rect.center().y - 9.0), Vec2::new(galley.size().x + 12.0, 18.0)); p.rect(cap, CornerRadius::same(5), theme::surface_3().gamma_multiply(0.8), Stroke::new(1.0, theme::border()), egui::StrokeKind::Inside); p.galley(Pos2::new(cap.left() + 6.0, cap.center().y - galley.size().y / 2.0), galley, theme::text_dim()); } @@ -212,7 +220,9 @@ impl TetherApp { ui.label(RichText::new("Primary on the").size(12.0).color(theme::text_dim())); w::segmented(ui, &mut l.sidebar_side, &[(Side::Left, "Left"), (Side::Right, "Right")]); }); - for (c, action) in [(Container::Primary, Action::ToggleSidebar), (Container::Secondary, Action::ToggleSecondarySidebar), (Container::Panel, Action::TogglePanel)] { + for (c, action) in + [(Container::Primary, Action::ToggleSidebar), (Container::Secondary, Action::ToggleSecondarySidebar), (Container::Panel, Action::TogglePanel)] + { let state = l.views.get_mut(c); let mut open = !state.collapsed; let label = if state.is_empty() { format!("{} (empty)", c.title()) } else { c.title().to_string() }; diff --git a/src/app_layout.rs b/src/app_layout.rs index 101a356..85d9854 100644 --- a/src/app_layout.rs +++ b/src/app_layout.rs @@ -23,9 +23,7 @@ impl TetherApp { for a in actions { match a { TabAction::Focus(uid) => { - if self.mini_dock.restore_by_uid(ctx, &mut self.dock, uid) { - self.show_tab(uid); - } else if !self.detached.focus_tab(ctx, uid) { + if self.mini_dock.restore_by_uid(ctx, &mut self.dock, uid) || !self.detached.focus_tab(ctx, uid) { self.show_tab(uid); } } @@ -38,25 +36,15 @@ impl TetherApp { self.show_tab(uid); } TabAction::StartRename(uid) => { - if let Some(tab) = self - .dock - .iter_all_tabs_mut() - .map(|(_, t)| t) - .chain(self.detached.tabs_mut()) - .chain(self.mini_dock.tabs_mut()) - .find(|t| t.uid == uid) + if let Some(tab) = + self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == uid) { self.rename = Some((uid, tab.display_title())); } } TabAction::Rename(uid, name) => { - if let Some(tab) = self - .dock - .iter_all_tabs_mut() - .map(|(_, t)| t) - .chain(self.detached.tabs_mut()) - .chain(self.mini_dock.tabs_mut()) - .find(|t| t.uid == uid) + if let Some(tab) = + self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == uid) { tab.rename(name); } @@ -313,7 +301,13 @@ impl TetherApp { let rect = ui.available_rect_before_wrap().shrink(6.0); let see_through = crate::themes::see_through(); let fill = if see_through { theme::surface().gamma_multiply(crate::themes::window_opacity()) } else { theme::surface() }; - ui.painter().rect(rect, egui::CornerRadius::same(theme::RADIUS), fill, egui::Stroke::new(1.0, theme::border().gamma_multiply(0.55)), egui::StrokeKind::Inside); + ui.painter().rect( + rect, + egui::CornerRadius::same(theme::RADIUS), + fill, + egui::Stroke::new(1.0, theme::border().gamma_multiply(0.55)), + egui::StrokeKind::Inside, + ); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(("zoomed", uid))); child.set_clip_rect(rect); viewer.ui(&mut child, tab); @@ -325,7 +319,13 @@ impl TetherApp { resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Maximized pane: restore")); let h = ui.ctx().animate_bool_with_time(resp.id, resp.hovered(), theme::anim_time(0.1)); let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("zoom_pill_layer"))); - fg.rect(pill, egui::CornerRadius::same(11), theme::surface_2().lerp_to_gamma(theme::surface_3(), h), egui::Stroke::new(1.0, theme::accent().gamma_multiply(0.5)), egui::StrokeKind::Inside); + fg.rect( + pill, + egui::CornerRadius::same(11), + theme::surface_2().lerp_to_gamma(theme::surface_3(), h), + egui::Stroke::new(1.0, theme::accent().gamma_multiply(0.5)), + egui::StrokeKind::Inside, + ); fg.galley(pill.min + egui::vec2(11.0, 5.0), galley, theme::text()); if resp.clicked() { self.toggle_zoom(); diff --git a/src/app_local.rs b/src/app_local.rs index 1fcb529..d1961ad 100644 --- a/src/app_local.rs +++ b/src/app_local.rs @@ -83,13 +83,7 @@ impl TetherApp { /// after the active one. pub(super) fn open_local_in(&mut self, picks: Vec<(egui_dock::NodePath, &'static ShellSpec)>, ctx: &egui::Context) { for (path, spec) in picks { - let inherited_cwd = self - .dock - .leaf_mut(path) - .ok() - .and_then(|leaf| leaf.tabs.get(leaf.active.0)) - .and_then(origin) - .and_then(|(_, cwd)| cwd); + let inherited_cwd = self.dock.leaf_mut(path).ok().and_then(|leaf| leaf.tabs.get(leaf.active.0)).and_then(origin).and_then(|(_, cwd)| cwd); let tab = self.local_tab(spec, inherited_cwd, ctx); let new_uid = tab.uid; match self.dock.leaf_mut(path) { @@ -107,13 +101,7 @@ impl TetherApp { pub(super) fn respawn_local(&mut self, uid: u64, ctx: &egui::Context) { let Some((spec, cwd)) = self.all_tabs().find(|t| t.uid == uid).and_then(origin) else { return }; let view = self.spawn_local(&spec, cwd, ctx); - let tab = self - .dock - .iter_all_tabs_mut() - .map(|(_, t)| t) - .chain(self.detached.tabs_mut()) - .chain(self.mini_dock.tabs_mut()) - .find(|t| t.uid == uid); + let tab = self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == uid); if let Some(Tab { body: TabBody::Terminal(old), .. }) = tab { *old = view; } @@ -126,18 +114,8 @@ pub(super) fn origin(tab: &Tab) -> Option<(ShellSpec, Option)> { let spec = tab.local.clone()?; let cwd = match &tab.body { TabBody::Terminal(t) => { - let (osc_cwd, pid) = t - .handle - .shared - .lock() - .ok() - .map(|s| (s.parser.callbacks().cwd.clone(), s.pid)) - .unwrap_or((None, None)); - crate::agents::hub::agent_cwd(tab.uid) - .or(osc_cwd) - .or_else(|| pid.and_then(live_pid_cwd)) - .or_else(|| tab.current_cwd()) - .map(PathBuf::from) + let (osc_cwd, pid) = t.handle.shared.lock().ok().map(|s| (s.parser.callbacks().cwd.clone(), s.pid)).unwrap_or((None, None)); + crate::agents::hub::agent_cwd(tab.uid).or(osc_cwd).or_else(|| pid.and_then(live_pid_cwd)).or_else(|| tab.current_cwd()).map(PathBuf::from) } _ => None, }; @@ -148,13 +126,6 @@ fn live_pid_cwd(pid: u32) -> Option { use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; let target = Pid::from_u32(pid); let mut sys = System::new(); - sys.refresh_processes_specifics( - ProcessesToUpdate::Some(&[target]), - true, - ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always), - ); - sys.process(target)? - .cwd() - .map(|p| p.to_string_lossy().into_owned()) - .filter(|s| !s.is_empty()) + sys.refresh_processes_specifics(ProcessesToUpdate::Some(&[target]), true, ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always)); + sys.process(target)?.cwd().map(|p| p.to_string_lossy().into_owned()).filter(|s| !s.is_empty()) } diff --git a/src/app_palette.rs b/src/app_palette.rs index a9dbe7a..803581f 100644 --- a/src/app_palette.rs +++ b/src/app_palette.rs @@ -85,8 +85,9 @@ impl TetherApp { r.add(Entry::new("Hosts", "Import hosts…", Command::ImportHosts).detail("Bitvise profiles, SSH config")); for l in crate::agents::launch::available() { let (kind, command) = (l.kind, l.command); - r.add(Entry::new("Agent", format!("Launch {}", kind.name()), Command::LaunchAgent { kind, command, pick_folder: false }).detail(command)); - r.add(Entry::new("Agent", format!("Launch {} in folder…", kind.name()), Command::LaunchAgent { kind, command, pick_folder: true })); + r.add( + Entry::new("Agent", format!("Launch {}…", kind.name()), Command::LaunchAgent { kind, command }).detail("Choose a project directory and shell"), + ); } for name in self.workspaces.names() { r.add(Entry::new("Workspace", format!("Restore {name}"), Command::RestoreWorkspace(name))); @@ -154,12 +155,13 @@ impl TetherApp { Command::RestoreWorkspace(name) => self.restore_workspace(&name, ctx), Command::OpenLayoutSettings => self.open_layout_settings(), Command::ImportHosts => self.import = Some(crate::ui::import_view::ImportDialog::new(&self.store)), - Command::LaunchAgent { kind, command, pick_folder } => { - let pick = crate::ui::agents_panel::LaunchPick { kind, command, pick_folder }; - let target = self.dock.focused_leaf().or_else(|| self.dock.iter_leaves().next().map(|(p, _)| p)).unwrap_or(egui_dock::NodePath { - surface: egui_dock::SurfaceIndex::main(), - node: egui_dock::NodeIndex::root(), - }); + Command::LaunchAgent { kind, command } => { + let pick = crate::ui::agents_panel::LaunchPick { kind, command: command.into(), shell_id: None, cwd: None }; + let target = self + .dock + .focused_leaf() + .or_else(|| self.dock.iter_leaves().next().map(|(p, _)| p)) + .unwrap_or(egui_dock::NodePath { surface: egui_dock::SurfaceIndex::main(), node: egui_dock::NodeIndex::root() }); self.open_agents_in(vec![(target, pick)], ctx); } Command::Run(f) => f(ctx), @@ -168,8 +170,8 @@ impl TetherApp { /// Runs a shortcut action (from its key or the palette). pub(super) fn run_action(&mut self, a: Action, ctx: &egui::Context) { - use egui_dock::Split; use crate::workspace::TabKind; + use egui_dock::Split; match a { Action::CommandPalette => { if self.palette.take().is_none() { @@ -226,6 +228,9 @@ impl TetherApp { } Action::NextAttention => self.focus_next_attention(ctx), Action::ToggleAgents => self.toggle_agents(), + Action::ToggleCodex => { + let _ = crate::settings::update(|s| s.layout.views.click(View::Codex)); + } // Handled by the focused terminal / hovered SFTP pane themselves. Action::PrevPrompt | Action::NextPrompt => { let up = a == Action::PrevPrompt; @@ -242,7 +247,6 @@ impl TetherApp { } } - fn category(g: Group) -> &'static str { match g { Group::General => "General", diff --git a/src/app_views.rs b/src/app_views.rs index 0ecaeee..7832474 100644 --- a/src/app_views.rs +++ b/src/app_views.rs @@ -42,7 +42,7 @@ impl Host<'_, '_> { View::Hosts => Some(Sidebar::host_count(self.data)), View::Sessions => Some(self.dock.iter_all_tabs().count() + self.vtabs.extra_rows.len()), View::Workspaces => Some(self.data.workspaces.len()), - View::Agents => Some(self.agents.len()), + View::Agents | View::Codex => Some(self.agents.iter().filter(|s| v.includes_agent(s.kind)).count()), } } @@ -58,7 +58,23 @@ impl Host<'_, '_> { self.tab_actions.extend(a); } View::Workspaces => self.sidebar.workspaces_header(ui, self.data), - View::Agents => {} + View::Agents | View::Codex => { + if crate::ui::settings_widgets::icon_button(ui, crate::ui::settings_widgets::Icon::Plus, 24.0).on_hover_text("Launch agent").clicked() { + let kind = v.agent().unwrap(); + self.agent_action = Some(PanelAction::Launch(agents_panel::LaunchPick { + kind, + command: agents_panel::default_command(kind).into(), + shell_id: None, + cwd: None, + })); + } + if crate::ui::settings_widgets::icon_button(ui, crate::ui::settings_widgets::Icon::Gear, 24.0) + .on_hover_text("Agent settings and hooks") + .clicked() + { + self.agent_action = Some(PanelAction::InstallHooks); + } + } } } @@ -78,9 +94,11 @@ impl Host<'_, '_> { self.actions.extend(a); }); } - View::Agents => { - egui::ScrollArea::vertical().id_salt(("view_agents", in_panel)).auto_shrink([false, false]).show(ui, |ui| { - self.agent_action = agents_panel::list(ui, self.agents, std::time::Instant::now()); + View::Agents | View::Codex => { + egui::ScrollArea::vertical().id_salt(("view_agents", v, in_panel)).auto_shrink([false, false]).show(ui, |ui| { + if let Some(action) = agents_panel::list_for(ui, self.agents, v, std::time::Instant::now()) { + self.agent_action = Some(action); + } }); // Elapsed times tick while sessions are listed. if !self.agents.is_empty() { @@ -98,7 +116,13 @@ impl Host<'_, '_> { let w = galley.size().x; p.galley(Pos2::new(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0), galley, theme::text_faint()); if let Some(n) = self.count(v).filter(|n| *n > 0) { - p.text(Pos2::new(rect.left() + 4.0 + w + 6.0, rect.center().y), Align2::LEFT_CENTER, n.to_string(), FontId::proportional(10.5), theme::text_faint().gamma_multiply(0.7)); + p.text( + Pos2::new(rect.left() + 4.0 + w + 6.0, rect.center().y), + Align2::LEFT_CENTER, + n.to_string(), + FontId::proportional(10.5), + theme::text_faint().gamma_multiply(0.7), + ); } let mut buttons = ui.new_child(UiBuilder::new().max_rect(rect).layout(egui::Layout::right_to_left(egui::Align::Center))); self.header_buttons(v, &mut buttons); @@ -154,13 +178,17 @@ impl TetherApp { }); } let tabs = self.dock.iter_all_tabs().count() + self.vtabs.extra_rows.len(); - let waiting = self.agents.attention_count(); - // The badge takes the status colour of the most urgent session. - let urgent = self.agents.sessions().iter().map(|s| s.state).filter(|s| s.needs_attention()).min_by_key(|s| s.urgency()); - let attention_tone = Some(urgent.map_or(theme::warn(), |s| crate::ui::agent_status::tone_color(crate::ui::agent_status::spec(s).tone))); + let agent_badge = |view: View| { + let sessions = self.agents.sessions().iter().filter(|s| view.includes_agent(s.kind)); + let count = sessions.clone().filter(|s| s.state.needs_attention()).count(); + let urgent = sessions.map(|s| s.state).filter(|s| s.needs_attention()).min_by_key(|s| s.urgency()); + Badge { count, tone: Some(urgent.map_or(theme::warn(), |s| crate::ui::agent_status::tone_color(crate::ui::agent_status::spec(s).tone))) } + }; + let badges = [agent_badge(View::Agents), agent_badge(View::Codex)]; let badge = move |v: View| match v { View::Sessions => Some(Badge { count: tabs, tone: None }), - View::Agents => Some(Badge { count: waiting, tone: attention_tone }), + View::Agents => Some(badges[0]), + View::Codex => Some(badges[1]), View::Hosts | View::Workspaces => None, }; let mut host = Host { @@ -207,6 +235,8 @@ impl TetherApp { self.settings_view.get_or_insert_with(SettingsView::new); } ViewEvent::Reset => reset_layout(ctx), + ViewEvent::LaunchAgent(kind) => self.launch_agent_command(kind, crate::ui::agents_panel::default_command(kind).into(), ctx), + ViewEvent::CustomBoot(kind) => self.custom_boot = Some(crate::ui::agents_panel::CustomBoot::new(kind)), e => { let _ = settings::update(|s| apply_to(&mut s.layout, e)); } @@ -224,7 +254,7 @@ fn apply_to(l: &mut LayoutSettings, e: ViewEvent) { ViewEvent::Reveal(view) => v.reveal(view), ViewEvent::Toggle(c) => v.toggle(c), ViewEvent::SetBar(c, bar) => v.get_mut(c).bar = bar, - ViewEvent::Reset | ViewEvent::OpenSettings => {} + ViewEvent::Reset | ViewEvent::OpenSettings | ViewEvent::LaunchAgent(_) | ViewEvent::CustomBoot(_) => {} } } @@ -318,12 +348,20 @@ fn panel_container(ui: &mut Ui, f: &mut Frame<'_>, host: &mut Host<'_, '_>) { let r = egui::Panel::show_switched(ui, &mut open, collapsed, expanded, |ui, expanded| { let (tabs, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), activity::TABS_H), Sense::hover()); let full = ui.max_rect(); - ui.painter().hline(full.expand2(Vec2::new(f32::from(PAD), 0.0)).x_range(), full.top() + 0.5, egui::Stroke::new(1.0, theme::border().gamma_multiply(0.45))); + ui.painter().hline( + full.expand2(Vec2::new(f32::from(PAD), 0.0)).x_range(), + full.top() + 0.5, + egui::Stroke::new(1.0, theme::border().gamma_multiply(0.45)), + ); let strip = Strip { container: Container::Panel, state, hidden: &f.l.views.hidden, style: StripStyle::Tabs, marker_right: false, badge: f.badge }; let strip_w = strip_width(ui, state, f.badge).min(tabs.width() * 0.6); strip.show(ui, Rect::from_min_size(tabs.min, Vec2::new(strip_w, tabs.height())), f.targets, &mut f.events); // Right side: the view's own buttons, then fold / unfold. - let mut right = ui.new_child(UiBuilder::new().max_rect(Rect::from_min_max(Pos2::new(tabs.left() + strip_w, tabs.top()), tabs.max)).layout(egui::Layout::right_to_left(egui::Align::Center))); + let mut right = ui.new_child( + UiBuilder::new() + .max_rect(Rect::from_min_max(Pos2::new(tabs.left() + strip_w, tabs.top()), tabs.max)) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); right.spacing_mut().item_spacing.x = 2.0; let tip = crate::keybindings::hint(if expanded { "Fold panel" } else { "Unfold panel" }, crate::keybindings::Action::TogglePanel); if fold_button(&mut right, expanded).on_hover_text(tip).clicked() { diff --git a/src/crashlog.rs b/src/crashlog.rs index afecf3e..78889a5 100644 --- a/src/crashlog.rs +++ b/src/crashlog.rs @@ -70,10 +70,7 @@ fn format_panic_report(info: &PanicHookInfo<'_>, backtrace: &str) -> String { let arch = std::env::consts::ARCH; let thread = std::thread::current(); let thread_name = thread.name().unwrap_or(""); - let location = info - .location() - .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) - .unwrap_or_else(|| "".to_string()); + let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_else(|| "".to_string()); let message = panic_payload_message(info); format!( diff --git a/src/import/discovery.rs b/src/import/discovery.rs new file mode 100644 index 0000000..b397c63 --- /dev/null +++ b/src/import/discovery.rs @@ -0,0 +1,122 @@ +//! Bounded discovery under OS-provided user folders; never scans disks or follows links. +use std::collections::{HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::{Candidate, bitvise}; + +#[derive(Default)] +pub struct Found { + pub keys: Vec, + pub profiles: Vec, + pub searched: Vec, + pub limited: bool, +} + +pub fn roots() -> Vec { + let mut dirs = bitvise::search_dirs(); + if let Some(u) = directories::UserDirs::new() { + dirs.extend(u.download_dir().map(Path::to_path_buf)); + for name in [".ssh", "Bitvise", "Bitvise SSH Client"] { + dirs.push(u.home_dir().join(name)); + } + } + if let Some(b) = directories::BaseDirs::new() { + for base in [b.config_dir(), b.data_local_dir(), b.data_dir()] { + for name in ["Bitvise", "Bitvise SSH Client", "BvSshClient"] { + dirs.push(base.join(name)); + } + } + } + let mut seen = HashSet::new(); + dirs.retain(|p| p.is_dir() && seen.insert(p.clone())); + dirs +} + +pub fn scan(roots: Vec) -> Found { + let mut found = Found { searched: roots.clone(), ..Default::default() }; + let mut queue: VecDeque<_> = roots.into_iter().map(|p| (p, 0)).collect(); + let mut visited = HashSet::new(); + let started = Instant::now(); + let mut entries_seen = 0; + while let Some((dir, depth)) = queue.pop_front() { + if started.elapsed() > Duration::from_secs(10) || entries_seen >= 20_000 { + found.limited = true; + break; + } + let Ok(canonical) = dir.canonicalize() else { continue }; + if !visited.insert(canonical) { + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + entries_seen += 1; + if entries_seen >= 20_000 || started.elapsed() > Duration::from_secs(10) { + found.limited = true; + break; + } + let Ok(kind) = entry.file_type() else { continue }; + let path = entry.path(); + if kind.is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if kind.is_dir() && depth < 4 && !["node_modules", ".git", "target", "cache"].contains(&name.as_str()) { + queue.push_back((path, depth + 1)); + } else if kind.is_file() && entry.metadata().is_ok_and(|m| m.len() < 1024 * 1024) { + if name.ends_with(".tlp") { + if let Some(c) = bitvise::load(&path) { + found.profiles.push(c); + } + } else if is_key_name(&name) && is_private_key(&path) { + found.keys.push(path); + } + } + } + } + found.keys.sort(); + found.keys.dedup(); + found.profiles.sort_by(|a, b| a.origin.cmp(&b.origin)); + found.profiles.dedup_by(|a, b| a.origin == b.origin); + found +} + +fn is_key_name(name: &str) -> bool { + !name.ends_with(".pub") && (name.starts_with("id_") || [".pem", ".key", ".ppk", ".prv"].iter().any(|ext| name.ends_with(ext)) || name.contains("private")) +} + +fn is_private_key(path: &Path) -> bool { + use std::io::Read; + let Ok(file) = std::fs::File::open(path) else { return false }; + let mut header = String::new(); + if file.take(160).read_to_string(&mut header).is_err() { + return false; + } + [ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "-----BEGIN RSA PRIVATE KEY-----", + "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN ENCRYPTED PRIVATE KEY-----", + ] + .iter() + .any(|h| header.starts_with(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn finds_generic_key_names_in_nested_folders_without_importing_them() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("Bitvise/Keys"); + std::fs::create_dir_all(&nested).unwrap(); + let key = nested.join("id_ed25519"); + std::fs::write(&key, "-----BEGIN OPENSSH PRIVATE KEY-----\nsample").unwrap(); + std::fs::write(nested.join("id_ed25519.pub"), "ssh-ed25519 public").unwrap(); + std::fs::write(nested.join("random.key"), "unrelated").unwrap(); + let found = scan(vec![dir.path().into(), nested]); + assert_eq!(found.keys, vec![key]); + assert!(!found.limited); + } +} diff --git a/src/import/mod.rs b/src/import/mod.rs index c78e0bc..4e5ec71 100644 --- a/src/import/mod.rs +++ b/src/import/mod.rs @@ -2,7 +2,9 @@ //! user, key file path) are imported; passwords stay where they are. pub mod bitvise; +pub mod discovery; pub mod openssh; +pub mod setup; use std::path::PathBuf; @@ -12,6 +14,7 @@ use crate::profile::{Profile, ProfileStore}; pub enum Source { Bitvise, OpenSsh, + Tether, } impl Source { @@ -19,6 +22,7 @@ impl Source { match self { Source::Bitvise => "Bitvise", Source::OpenSsh => "SSH config", + Source::Tether => "Tether setup", } } } diff --git a/src/import/setup.rs b/src/import/setup.rs new file mode 100644 index 0000000..40036a7 --- /dev/null +++ b/src/import/setup.rs @@ -0,0 +1,98 @@ +//! Portable connection metadata. This schema cannot serialize credentials or key paths. +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; + +use super::{Candidate, Source}; +use crate::profile::Profile; + +const FORMAT: &str = "tether-hosts"; +const VERSION: u32 = 1; +const MAX_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Setup { + format: String, + version: u32, + hosts: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Host { + name: String, + host: String, + port: u16, + user: String, + hue: f32, +} + +pub fn encode(profiles: &[Profile]) -> Result { + let hosts = profiles.iter().map(|p| Host { name: p.name.clone(), host: p.host.clone(), port: p.port, user: p.user.clone(), hue: p.hue }).collect(); + serde_json::to_string_pretty(&Setup { format: FORMAT.into(), version: VERSION, hosts }).context("encoding setup") +} + +pub fn decode(text: &str) -> Result> { + ensure!(text.len() as u64 <= MAX_BYTES, "Setup file exceeds 4 MiB"); + let setup: Setup = serde_json::from_str(text).context("Not a Tether setup file")?; + ensure!(setup.format == FORMAT && setup.version == VERSION, "Unsupported Tether setup format or version"); + ensure!(setup.hosts.len() <= 10_000, "Setup has too many hosts"); + setup + .hosts + .into_iter() + .map(|h| { + ensure!(h.name.len() <= 256 && h.host.len() <= 253 && h.user.len() <= 256, "Host details are too long"); + ensure!( + !h.host.chars().any(char::is_control) && !h.user.chars().any(char::is_control) && !h.name.chars().any(char::is_control), + "Host details contain control characters" + ); + ensure!(h.hue.is_finite() && (0.0..=360.0).contains(&h.hue), "Invalid host color"); + let mut profile = Profile::new(); + profile.name = h.name; + profile.host = h.host; + profile.port = h.port; + profile.user = h.user; + profile.hue = h.hue; + profile.validate().map_err(anyhow::Error::msg)?; + Ok(profile) + }) + .collect() +} + +pub fn load(path: &Path) -> Result> { + ensure!(std::fs::metadata(path)?.len() <= MAX_BYTES, "Setup file exceeds 4 MiB"); + Ok(decode(&std::fs::read_to_string(path)?)?.into_iter().map(|profile| Candidate { source: Source::Tether, origin: path.into(), profile }).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::AuthMethod; + + #[test] + fn roundtrip_excludes_every_credential_and_generates_new_ids() { + let mut p = Profile::parse_quick("alice@10.0.0.1:2222").unwrap(); + p.name = "Production".into(); + p.auth = AuthMethod::Key { path: "secret/private-key".into() }; + let text = encode(&[p.clone()]).unwrap(); + for secret in ["auth", "secret", "path", "password", "key", "folder", "id"] { + assert!(!text.contains(secret)); + } + let imported = decode(&text).unwrap().remove(0); + assert_ne!(p.id, imported.id); + assert_eq!(imported.auth, AuthMethod::Password); + assert_eq!(imported.address(), p.address()); + assert_eq!(imported.name, "Production"); + } + + #[test] + fn invalid_host_or_version_rejects_whole_file() { + let p = Profile::parse_quick("alice@host").unwrap(); + let text = encode(&[p]).unwrap(); + assert!(decode(&text.replace("\"version\": 1", "\"version\": 2")).is_err()); + assert!(decode(&text.replace("\"port\": 22", "\"port\": 0")).is_err()); + assert!(decode(&text.replace("\"port\": 22", "\"password\": \"no\", \"port\": 22")).is_err()); + } +} diff --git a/src/keybindings.rs b/src/keybindings.rs index 0f60399..754c0fd 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -25,6 +25,7 @@ pub enum Action { PrevTab, NextAttention, ToggleAgents, + ToggleCodex, FontBigger, FontSmaller, FontReset, @@ -74,7 +75,7 @@ impl Group { } impl Action { - pub const ALL: [Action; 40] = [ + pub const ALL: [Action; 41] = [ Action::CommandPalette, Action::ToggleSidebar, Action::ToggleSecondarySidebar, @@ -97,6 +98,7 @@ impl Action { Action::PrevTab, Action::NextAttention, Action::ToggleAgents, + Action::ToggleCodex, Action::FontBigger, Action::FontSmaller, Action::FontReset, @@ -141,6 +143,7 @@ impl Action { Action::PrevTab => "Previous tab", Action::NextAttention => "Next agent session needing attention", Action::ToggleAgents => "Toggle the agents panel", + Action::ToggleCodex => "Toggle the Codex panel", Action::FontBigger => "Larger font", Action::FontSmaller => "Smaller font", Action::FontReset => "Reset font size", @@ -186,6 +189,7 @@ impl Action { | Action::PrevTab | Action::NextAttention | Action::ToggleAgents + | Action::ToggleCodex | Action::RenameTab => Group::Sessions, Action::FocusPaneLeft | Action::FocusPaneRight @@ -215,7 +219,7 @@ impl Action { // is a dead key on many layouts. Action::TogglePanel => cs(Key::Y), Action::FocusSessionsView => cs(Key::S), - Action::FocusWorkspacesView | Action::ResetLayout => None, + Action::FocusWorkspacesView | Action::ToggleCodex | Action::ResetLayout => None, Action::OpenSettings => c(Key::Comma), Action::KeyManager => cs(Key::K), Action::NewHost => cs(Key::N), diff --git a/src/layout.rs b/src/layout.rs index df50429..26f4c17 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -400,7 +400,7 @@ mod tests { let l: LayoutSettings = serde_json::from_str(old).unwrap(); assert_eq!(l.tab_position, TabPosition::Top); assert_eq!(l.sidebar_side, Side::Right); - assert_eq!(l.views.primary.views, vec![View::Workspaces, View::Hosts, View::Sessions, View::Agents]); + assert_eq!(l.views.primary.views, vec![View::Workspaces, View::Hosts, View::Sessions, View::Agents, View::Codex]); assert_eq!(l.views.primary.active, Some(View::Sessions)); assert!(l.views.primary.collapsed, "a hidden sidebar is a folded one"); assert_eq!(l.views.size(Container::Primary), 300.0); diff --git a/src/lib.rs b/src/lib.rs index e3de323..130f517 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,15 +13,17 @@ pub mod keybindings; pub mod keystore; pub mod known_hosts; pub mod layout; +pub mod local_projects; pub mod procmon; pub mod profile; pub mod pty; pub mod remote_edit; pub mod repaint; pub mod settings; -pub mod shell_integration; pub mod sftp_history; +pub mod shell_integration; pub mod ssh; +pub mod terminal_capabilities; pub mod theme; pub mod theme_catalog; pub mod theme_import; diff --git a/src/local_projects.rs b/src/local_projects.rs new file mode 100644 index 0000000..cfd2311 --- /dev/null +++ b/src/local_projects.rs @@ -0,0 +1,93 @@ +//! Local project shortcuts and bounded directory history, shared by both launchers. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Project { + pub path: String, + pub name: String, + pub saved: bool, + pub visits: u32, + pub last_visit: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Projects { + pub entries: Vec, + pub last_directory: String, +} + +impl Projects { + pub fn record(&mut self, path: &str, now: u64) { + let path = path.trim_end_matches(['/', '\\']); + // Keep filesystem roots intact. + let path = if path.is_empty() || path.ends_with(':') { format!("{path}{}", std::path::MAIN_SEPARATOR) } else { path.to_owned() }; + self.last_directory.clone_from(&path); + if let Some(p) = self.entries.iter_mut().find(|p| same_path(&p.path, &path)) { + p.visits = p.visits.saturating_add(1); + p.last_visit = now; + } else { + let name = PathBuf::from(&path).file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.clone()); + self.entries.push(Project { path, name, saved: false, visits: 1, last_visit: now }); + } + self.entries.sort_by_key(|p| (std::cmp::Reverse(p.saved), std::cmp::Reverse(p.last_visit))); + let mut recent = 0; + self.entries.retain(|p| { + if p.saved { + true + } else { + recent += 1; + recent <= 32 + } + }); + } +} + +pub fn same_path(a: &str, b: &str) -> bool { + let norm = |s: &str| { + let s = s.replace('\\', "/").trim_end_matches('/').to_owned(); + if cfg!(windows) { s.to_lowercase() } else { s } + }; + norm(a) == norm(b) +} + +pub fn home() -> PathBuf { + directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()).unwrap_or_else(|| PathBuf::from(".")) +} + +pub fn resolve(text: &str) -> PathBuf { + let text = text.trim().trim_matches('"'); + if text == "~" { + home() + } else if let Some(rest) = text.strip_prefix("~/").or_else(|| text.strip_prefix("~\\")) { + home().join(rest) + } else { + let path = PathBuf::from(text); + if path.is_absolute() { path } else { home().join(path) } + } +} + +pub fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn recent_history_is_bounded_but_saved_projects_survive() { + let mut projects = Projects::default(); + projects.record("/saved project", 1); + projects.entries[0].saved = true; + for n in 0..50 { + projects.record(&format!("/project{n}"), n + 2); + } + assert_eq!(projects.entries.len(), 33); + assert!(projects.entries[0].saved); + projects.record("/project49/", 100); + assert_eq!(projects.entries.len(), 33); + assert_eq!(projects.entries.iter().find(|p| p.path.ends_with("project49")).unwrap().visits, 2); + assert_eq!(resolve("~/project with spaces"), home().join("project with spaces")); + } +} diff --git a/src/procmon/mod.rs b/src/procmon/mod.rs index 039733b..31cdd42 100644 --- a/src/procmon/mod.rs +++ b/src/procmon/mod.rs @@ -38,14 +38,11 @@ pub struct Snapshot { /// What the UI shows of a usage (see [`Usage::visible_key`]). type VisibleKey = (i32, u64, Option); +type VisibleTab = (u64, VisibleKey, bool, Option); impl Snapshot { - fn visible_keys(&self) -> (Vec<(u64, VisibleKey, bool, Option)>, VisibleKey) { - let mut v: Vec<_> = self - .per_tab - .iter() - .map(|(k, u)| (*k, u.visible_key(), u.subprocs_busy, u.cwd.clone())) - .collect(); + fn visible_keys(&self) -> (Vec, VisibleKey) { + let mut v: Vec<_> = self.per_tab.iter().map(|(k, u)| (*k, u.visible_key(), u.subprocs_busy, u.cwd.clone())).collect(); v.sort_unstable_by_key(|(k, ..)| *k); (v, self.total.visible_key()) } @@ -210,11 +207,7 @@ impl Sampler { // know the live working directory even when OSC 7 is off. let watched_pids: Vec = watch.values().copied().map(Pid::from_u32).collect(); if !watched_pids.is_empty() { - self.sys.refresh_processes_specifics( - ProcessesToUpdate::Some(&watched_pids), - false, - ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always), - ); + self.sys.refresh_processes_specifics(ProcessesToUpdate::Some(&watched_pids), false, ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always)); } // Command lines only for launchers in our trees we have not classified yet. let mut unknown = Vec::new(); diff --git a/src/procmon/tree.rs b/src/procmon/tree.rs index 406f103..6867a41 100644 --- a/src/procmon/tree.rs +++ b/src/procmon/tree.rs @@ -123,19 +123,13 @@ impl Table { Some(ap) => { let desc_pids = self.descendants(ap.pid); let has_active_child = desc_pids.iter().copied().filter(|&p| p != ap.pid).filter_map(|p| self.row(p)).any(|r| { - !is_passive_helper(&r.name) - && (r.agent.is_some() - || is_tool_or_shell_proc(&r.name) - || r.start > ap.start.saturating_add(2) - || r.cpu >= 0.8) + !is_passive_helper(&r.name) && (r.agent.is_some() || is_tool_or_shell_proc(&r.name) || r.start > ap.start.saturating_add(2) || r.cpu >= 0.8) }); has_active_child || ap.cpu >= 2.0 } None => false, }; - let cwd = agent_row - .and_then(|r| r.cwd.clone()) - .or_else(|| rows.first().and_then(|r| r.cwd.clone())); + let cwd = agent_row.and_then(|r| r.cwd.clone()).or_else(|| rows.first().and_then(|r| r.cwd.clone())); Some(Usage { cpu: rows.iter().map(|r| r.cpu).sum(), mem: rows.iter().map(|r| r.mem).sum(), diff --git a/src/pty/local.rs b/src/pty/local.rs index 4b9eb2a..18fabfa 100644 --- a/src/pty/local.rs +++ b/src/pty/local.rs @@ -14,7 +14,7 @@ use tokio::sync::mpsc; use super::shells::ShellSpec; use super::tree::ProcessTree; -use crate::ssh::terminal::{LinkState, TERM_NAME, TermCmd, TerminalHandle}; +use crate::ssh::terminal::{LinkState, TermCmd, TerminalHandle}; const READ_BUF: usize = 64 * 1024; /// How long to wait for trailing output once the shell has exited. @@ -47,10 +47,8 @@ pub fn run_local_pty( } None => cmd.args(&spec.args), } - cmd.env("TERM", TERM_NAME); - cmd.env("COLORTERM", "truecolor"); + crate::terminal_capabilities::local(&mut cmd); cmd.env("TETHER_SESSION", &term.tag); - cmd.env("TERM_PROGRAM", "tether"); // Where `tether hook` reports agent status (see `agents::ipc`). for (key, value) in crate::agents::ipc::session_env() { cmd.env(key, value); diff --git a/src/pty/shells.rs b/src/pty/shells.rs index e36cc94..56205d8 100644 --- a/src/pty/shells.rs +++ b/src/pty/shells.rs @@ -64,7 +64,8 @@ impl ShellSpec { "cmd" => "cmd".into(), "pwsh" => { // `…\PowerShell\7\pwsh.exe` -> "pwsh 7". - let version = self.program.parent().and_then(|d| d.file_name()).and_then(|v| v.to_str()).filter(|v| v.starts_with(|c: char| c.is_ascii_digit())); + let version = + self.program.parent().and_then(|d| d.file_name()).and_then(|v| v.to_str()).filter(|v| v.starts_with(|c: char| c.is_ascii_digit())); version.map_or_else(|| "pwsh".into(), |v| format!("pwsh {v}")) } id => id.strip_prefix("wsl:").map_or_else(|| self.name.clone(), str::to_string), @@ -196,7 +197,8 @@ fn detect() -> Vec { if let Some(cmd) = first_file(env_dir("ComSpec").into_iter().chain([system32.join("cmd.exe")])) { out.push(ShellSpec::new("cmd", "Command Prompt", cmd, &[], ShellIcon::Cmd)); } - let git_roots = git_install_dir().into_iter().chain(program_files.iter().map(|pf| pf.join("Git"))).chain(env_dir("LOCALAPPDATA").map(|d| d.join(r"Programs\Git"))); + let git_roots = + git_install_dir().into_iter().chain(program_files.iter().map(|pf| pf.join("Git"))).chain(env_dir("LOCALAPPDATA").map(|d| d.join(r"Programs\Git"))); if let Some(bash) = first_file(git_roots.map(|root| root.join(r"bin\bash.exe"))) { out.push(ShellSpec::new("git-bash", "Git Bash", bash, &["--login", "-i"], ShellIcon::GitBash)); } @@ -340,7 +342,10 @@ mod tests { #[test] fn default_falls_back_to_first_shell() { - let shells = vec![ShellSpec::new("pwsh", "PowerShell", "pwsh.exe", &[], ShellIcon::PowerShell), ShellSpec::new("cmd", "Command Prompt", "cmd.exe", &[], ShellIcon::Cmd)]; + let shells = vec![ + ShellSpec::new("pwsh", "PowerShell", "pwsh.exe", &[], ShellIcon::PowerShell), + ShellSpec::new("cmd", "Command Prompt", "cmd.exe", &[], ShellIcon::Cmd), + ]; assert_eq!(pick_default(&shells, "cmd").map(|s| s.id.as_str()), Some("cmd")); assert_eq!(pick_default(&shells, "").map(|s| s.id.as_str()), Some("pwsh")); assert_eq!(pick_default(&shells, "wsl:Gone").map(|s| s.id.as_str()), Some("pwsh")); diff --git a/src/settings.rs b/src/settings.rs index a4b863e..b2013fc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -217,6 +217,8 @@ pub struct AgentSettings { pub notify_on_done: bool, /// Sampling period of the resource monitor (slower while tether is in the background). pub monitor_interval_ms: u64, + /// Shell chosen in Custom Boot, by agent id. Separate from the normal terminal default. + pub launch_shells: BTreeMap, } impl AgentSettings { @@ -230,7 +232,15 @@ impl AgentSettings { impl Default for AgentSettings { fn default() -> Self { - Self { show_usage: true, badges: true, desktop_notifications: true, flash_taskbar: true, notify_on_done: true, monitor_interval_ms: 1500 } + Self { + show_usage: true, + badges: true, + desktop_notifications: true, + flash_taskbar: true, + notify_on_done: true, + monitor_interval_ms: 1500, + launch_shells: BTreeMap::new(), + } } } @@ -257,6 +267,7 @@ impl Default for EditorSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct Settings { + pub projects: crate::local_projects::Projects, pub appearance: Appearance, pub terminal: TerminalSettings, pub sftp: SftpSettings, diff --git a/src/shell_integration/bash.sh b/src/shell_integration/bash.sh index 130039b..cdbed42 100644 --- a/src/shell_integration/bash.sh +++ b/src/shell_integration/bash.sh @@ -123,6 +123,8 @@ __tether_preexec() { __tether_ran=1 } +# Insert a literal newline in Readline without accepting the command. +bind '"\e\r": "\C-v\C-j"' 2>/dev/null __tether_last_hist=$(HISTTIMEFORMAT= builtin history 1) if [[ -n "${bash_preexec_imported-}${__bp_imported-}" ]]; then diff --git a/src/shell_integration/blocks.rs b/src/shell_integration/blocks.rs index 6f89224..1842c5e 100644 --- a/src/shell_integration/blocks.rs +++ b/src/shell_integration/blocks.rs @@ -273,6 +273,19 @@ impl ShellState { &self.blocks } + /// Drop decorations for content actually erased by ED/RIS, preserving blocks + /// wholly in scrollback. Called at the escape sequence, before later marks. + pub fn erase_display(&mut self, screen: &mut vt100::Screen, history: bool) { + self.sync_inner(screen, false); + let top = self.lines.top_line(0); + self.blocks.retain(|b| !history && b.last_line() < top); + self.pending_command = None; + self.clear_votes = 0; + // Erased screen rows are no longer valid eviction anchors. + self.lines.anchor = None; + self.version += 1; + } + pub fn branch(&self) -> Option<&str> { self.branch.as_deref() } @@ -423,7 +436,13 @@ impl ShellState { let lazy = self.lazy_marks; if let Some(b) = self.blocks.back_mut().filter(|b| b.phase == Phase::Running) { // Lazy marks: the end is fixed up when the next prompt settles. - let end = if lazy { 0 } else if col > 0 { line + 1 } else { line }; + let end = if lazy { + 0 + } else if col > 0 { + line + 1 + } else { + line + }; b.phase = Phase::Done; b.exit = exit; b.end_line = Some(end.max(b.output_line.unwrap_or(end))); diff --git a/src/shell_integration/fish.fish b/src/shell_integration/fish.fish index ddf5433..b87d30e 100644 --- a/src/shell_integration/fish.fish +++ b/src/shell_integration/fish.fish @@ -82,6 +82,7 @@ if set -q TETHER_SESSION; and not set -q __tether_si; and status is-interactive printf '%s> ' $PWD end end + bind \e\r 'commandline -i \n' function fish_prompt set -l code $status printf '\e]133;A\a' diff --git a/src/shell_integration/mod.rs b/src/shell_integration/mod.rs index 233b12c..d705373 100644 --- a/src/shell_integration/mod.rs +++ b/src/shell_integration/mod.rs @@ -254,7 +254,10 @@ esac /// always LF, whatever git checked out. pub fn files() -> Vec<(&'static str, String)> { let lf = |s: &str| s.replace("\r\n", "\n"); - let zshrc = zsh_file(".zshrc", &format!("[[ -r ${{ZDOTDIR}}/tether.zsh ]] && . ${{ZDOTDIR}}/tether.zsh\n[[ -o login ]] || {{ {} }}\n", ZSH_RESTORE.replace('\n', "; "))); + let zshrc = zsh_file( + ".zshrc", + &format!("[[ -r ${{ZDOTDIR}}/tether.zsh ]] && . ${{ZDOTDIR}}/tether.zsh\n[[ -o login ]] || {{ {} }}\n", ZSH_RESTORE.replace('\n', "; ")), + ); vec![ ("bash-rc.sh", lf(&format!("{BASH_RC_PRELUDE}\n{BASH}"))), ("fish.fish", lf(FISH)), diff --git a/src/shell_integration/pwsh.ps1 b/src/shell_integration/pwsh.ps1 index 39d2e28..7700349 100644 --- a/src/shell_integration/pwsh.ps1 +++ b/src/shell_integration/pwsh.ps1 @@ -83,6 +83,11 @@ function global:__tether_hook_readline { $current = Get-Command PSConsoleHostReadLine -CommandType Function -ErrorAction Ignore if (-not $current -or $current.ScriptBlock.ToString().Contains('__tether_si')) { return } $s.ReadLine = $current.ScriptBlock + # Tether sends Meta+Enter for Shift+Enter (also understood by agent TUIs). + if (Get-Command Set-PSReadLineKeyHandler -ErrorAction Ignore) { + Set-PSReadLineKeyHandler -Chord 'Alt+Enter' -Function AddLine + Set-PSReadLineKeyHandler -Chord 'Shift+Enter' -Function AddLine + } function global:PSConsoleHostReadLine { $s = $global:__tether_si $line = & $s.ReadLine diff --git a/src/shell_integration/zsh.zsh b/src/shell_integration/zsh.zsh index d8b285a..4e58b30 100644 --- a/src/shell_integration/zsh.zsh +++ b/src/shell_integration/zsh.zsh @@ -75,6 +75,9 @@ __tether_preexec() { } autoload -Uz add-zsh-hook +__tether_add_line() { LBUFFER+=$'\n' } +zle -N __tether_add_line +bindkey $'\e\r' __tether_add_line add-zsh-hook precmd __tether_precmd add-zsh-hook preexec __tether_preexec # Run first among precmd hooks so $? is still the command's, and wrap PS1 last: themes diff --git a/src/ssh/cwd.rs b/src/ssh/cwd.rs index bfece56..89e9622 100644 --- a/src/ssh/cwd.rs +++ b/src/ssh/cwd.rs @@ -112,10 +112,74 @@ pub async fn terminal_cwd(conn: &Connection, probe: Probe<'_>) -> Result pick(&text, &probe).ok_or_else(|| anyhow::anyhow!("Couldn't tell which shell belongs to this terminal. Tip: let the shell report its folder (OSC 7)")) } +/// Usernames accepted in a prompt's named-home reference. No shell syntax is allowed. +pub fn valid_home_user(user: &str) -> bool { + !user.is_empty() && user.len() <= 256 && !user.starts_with('-') && user.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.')) +} + +pub fn user_home_command(user: &str) -> Result { + if !valid_home_user(user) { + bail!("Invalid username in remote home path"); + } + // NSS-aware on Linux; /etc/passwd also covers systems without getent. The + // validated username cannot escape the quotes or introduce shell expansion. + Ok(format!("sh -c 'getent passwd \"{user}\" 2>/dev/null || grep -F \"{user}:\" /etc/passwd'")) +} + +fn parse_user_home(output: &str, user: &str) -> Result { + for entry in output.lines() { + let mut fields = entry.split(':'); + if fields.next() != Some(user) { + continue; + } + if let Some(home) = fields.nth(4) + && home.starts_with('/') + && home.len() <= MAX_PATH + && !home.chars().any(char::is_control) + { + return Ok(home.into()); + } + } + bail!("Could not resolve the remote home directory for {user}") +} + +pub async fn user_home(conn: &Connection, user: &str) -> Result { + let command = user_home_command(user)?; + let (out, _) = tokio::time::timeout(EXEC_TIMEOUT, exec(conn, command, MAX_OUTPUT)).await.context("timed out resolving remote user's home")??; + parse_user_home(std::str::from_utf8(&out).context("invalid remote home reply")?, user) +} + +/// Resolve remote paths for both SFTP navigation and the editor. In particular, +/// ~prisma uses prisma's actual home, not the SSH transport's authenticated home. +pub async fn resolve_path(conn: &Connection, sftp: &super::sftp::Sftp, path: &str, cwd: &str) -> Result { + if path.starts_with('/') { + return Ok(path.into()); + } + if let Some(rest) = path.strip_prefix('~') { + let (user, tail) = rest.split_once('/').unwrap_or((rest, "")); + let home = if user.is_empty() { sftp.canonicalize(".").await? } else { user_home(conn, user).await? }; + return Ok(if tail.is_empty() { home } else { super::sftp::join(&home, tail) }); + } + Ok(super::sftp::join(cwd, path)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn named_home_uses_the_prompt_account_and_its_actual_server_home() { + let passwd = "root:x:0:0:root:/root:/bin/bash\nprisma:x:1001:1001::/srv/custom home/prisma:/bin/bash\n"; + assert_eq!(parse_user_home(passwd, "prisma").unwrap(), "/srv/custom home/prisma"); + assert_eq!(parse_user_home(passwd, "root").unwrap(), "/root"); + assert!(parse_user_home(passwd, "missing").is_err()); + assert!(parse_user_home("prisma:x:1:1::relative:/bin/sh", "prisma").is_err()); + assert!(user_home_command("prisma").unwrap().contains("getent passwd \"prisma\"")); + for bad in ["", "-root", "prisma;id", "$(id)", "a\"b", "a'b", "a/b", "a\nb"] { + assert!(user_home_command(bad).is_err(), "{bad:?}"); + } + } + const PROBE: Probe = Probe { tag: "ab12", age_secs: 30.0, rows: 40, cols: 120 }; #[test] diff --git a/src/ssh/terminal.rs b/src/ssh/terminal.rs index 99704d4..8c37716 100644 --- a/src/ssh/terminal.rs +++ b/src/ssh/terminal.rs @@ -10,7 +10,7 @@ use tokio::sync::mpsc; use super::connection::{Connection, Stage}; use crate::repaint::RepaintThrottle; -pub const TERM_NAME: &str = "xterm-256color"; +pub const TERM_NAME: &str = crate::terminal_capabilities::TERM; /// Fallback scrollback; the live value comes from `settings.terminal.scrollback`. pub const SCROLLBACK_LINES: usize = 10_000; @@ -36,6 +36,7 @@ impl LinkState { /// Captures OSC title changes and bells from the remote. #[derive(Default)] pub struct Events { + pub clear_detector: ClearDetector, pub title: Option, pub bell_count: u64, /// Working directory reported by the shell via OSC 7 (`file://host/path`), if it does. @@ -145,6 +146,55 @@ pub fn new_parser(rows: u16, cols: u16, scrollback: usize) -> vt100::Parser, mut bytes: &[u8]) { + // Observe actual erase sequences, including sequences split across PTY reads. + // Cursor-position heuristics alone miss ConPTY's final (and often only) repaint. + let mut detector = std::mem::take(&mut parser.callbacks_mut().clear_detector); + let mut start = 0; + for (i, byte) in bytes.iter().enumerate() { + let mut request = ClearRequest::default(); + detector.parser.advance(&mut request, &[*byte]); + if let Some(mode) = request.0 { + feed_inner(parser, &bytes[start..=i]); + let screen = parser.screen(); + if !screen.alternate_screen() && (mode != 0 || screen.cursor_position() == (0, 0)) { + let mut shell = std::mem::take(&mut parser.callbacks_mut().shell); + shell.erase_display(parser.screen_mut(), mode == 3 || mode == 4); + parser.callbacks_mut().shell = shell; + } + start = i + 1; + } + } + bytes = &bytes[start..]; + feed_inner(parser, bytes); + parser.callbacks_mut().clear_detector = detector; +} + +#[derive(Default)] +pub struct ClearDetector { + parser: vte::Parser, +} + +#[derive(Default)] +struct ClearRequest(Option); + +impl vte::Perform for ClearRequest { + fn csi_dispatch(&mut self, params: &vte::Params, intermediates: &[u8], ignore: bool, action: char) { + if action == 'J' && intermediates.is_empty() && !ignore { + let mode = params.iter().next().and_then(|p| p.first()).copied().unwrap_or(0); + if matches!(mode, 0 | 2 | 3) { + self.0 = Some(mode); + } + } + } + + fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) { + if byte == b'c' && intermediates.is_empty() && !ignore { + self.0 = Some(4); // RIS resets the screen and history. + } + } +} + +fn feed_inner(parser: &mut vt100::Parser, mut bytes: &[u8]) { let Some(step) = parser.callbacks().shell.sync_every() else { parser.process(bytes); return; @@ -286,6 +336,13 @@ pub async fn run_shell( repaint(); let channel = conn.handle.channel_open_session().await?; channel.request_pty(false, TERM_NAME, cols.into(), rows.into(), 0, 0, &[]).await?; + // Optional SSH env requests: servers may restrict AcceptEnv; PTY TERM still + // advertises 256 colors when those extra hints are rejected. + for &(key, value) in crate::terminal_capabilities::ENV { + if key != "TERM" { + let _ = channel.set_env(false, key, value).await; + } + } // Most servers accept LC_* (Debian/Ubuntu `AcceptEnv LANG LC_*`); harmless if refused. let _ = channel.set_env(false, "LC_TETHER_ID", term.tag.clone()).await; channel.request_shell(false).await?; @@ -350,6 +407,32 @@ pub async fn run_shell( mod tests { use super::*; + #[test] + fn erase_clears_lazy_command_decorations_with_no_further_output() { + for erase in [b"\x1b[2J\x1b[H".as_slice(), b"\x1b[H\x1b[J", b"\x1bc"] { + let mut parser = new_parser(24, 80, 100); + parser.callbacks_mut().shell.set_lazy_marks(true); + feed(&mut parser, b"\x1b]133;A\x07PS> \x1b]133;B\x07bad-command\r\n\x1b]6973;cmd;bad-command\x07\x1b]133;C\x07not found\r\n\x1b]133;D;1\x07\x1b]133;A\x07PS> \x1b]133;B\x07"); + assert!(!parser.callbacks().shell.blocks().is_empty()); + // Every possible boundary: ConPTY may split even ESC from its '['. + for byte in erase { + feed(&mut parser, &[*byte]); + } + assert!(parser.callbacks().shell.blocks().is_empty(), "erase {erase:?}"); + feed(&mut parser, b"\x1b]133;A\x07PS> \x1b]133;B\x07"); + assert_eq!(parser.callbacks().shell.blocks().len(), 1); + } + } + + #[test] + fn partial_erase_and_quoted_escape_text_preserve_marks() { + let mut parser = new_parser(24, 80, 100); + feed(&mut parser, b"\x1b]133;A\x07$ \x1b]133;B\x07echo ok\r\n\x1b]133;C\x07ok\r\n\x1b]133;D;0\x07"); + let n = parser.callbacks().shell.blocks().len(); + feed(&mut parser, b"\x1b[J\x1b]2;literal [2J in title\x07"); + assert_eq!(parser.callbacks().shell.blocks().len(), n); + } + fn parser() -> vt100::Parser { vt100::Parser::new_with_callbacks(24, 80, 0, Events::default()) } diff --git a/src/terminal_capabilities.rs b/src/terminal_capabilities.rs new file mode 100644 index 0000000..5a2f4bd --- /dev/null +++ b/src/terminal_capabilities.rs @@ -0,0 +1,13 @@ +//! Capabilities shared by local PTYs and remote SSH terminals. UI processes may be +//! launched by a non-interactive runner; its color policy is not the terminal's. + +pub const TERM: &str = "xterm-256color"; +pub const ENV: &[(&str, &str)] = + &[("TERM", TERM), ("COLORTERM", "truecolor"), ("TERM_PROGRAM", "tether"), ("FORCE_COLOR", "3"), ("CLICOLOR", "1"), ("CLICOLOR_FORCE", "1")]; + +pub fn local(cmd: &mut portable_pty::CommandBuilder) { + cmd.env_remove("NO_COLOR"); + for &(key, value) in ENV { + cmd.env(key, value); + } +} diff --git a/src/theme.rs b/src/theme.rs index 40fd22f..c483fa6 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -56,14 +56,14 @@ impl Palette { text: rgb(0xe2, 0xe3, 0xe8), text_dim: rgb(0x8d, 0x91, 0xa5), text_faint: rgb(0x5a, 0x5f, 0x7a), - accent: rgb(0x21, 0xb5, 0x68), - accent_soft: Color32::from_rgba_premultiplied(0x0d, 0x48, 0x2a, 0x60), + accent: rgb(0x17, 0x87, 0x4f), + accent_soft: Color32::from_rgba_premultiplied(0x05, 0x1b, 0x10, 0x33), blue: rgb(0x44, 0xa7, 0xff), danger: rgb(0xf2, 0x4e, 0x50), warn: rgb(0xfd, 0xc4, 0x7d), success: rgb(0x21, 0xb5, 0x68), shadow: Color32::from_black_alpha(140), - on_accent: rgb(0x07, 0x1a, 0x10), + on_accent: Color32::WHITE, icon_page: rgb(0x3a, 0x40, 0x5e), icon_page_edge: rgb(0x56, 0x5d, 0x80), row_separator: Color32::from_rgba_premultiplied(0x10, 0x12, 0x1c, 0x60), diff --git a/src/ui/activity.rs b/src/ui/activity.rs index e8f75f1..69cdc22 100644 --- a/src/ui/activity.rs +++ b/src/ui/activity.rs @@ -31,13 +31,19 @@ pub struct ViewDrag(pub View); pub enum ViewEvent { Click(View), /// Into `to` before the view now at `index` (`None` = the end). - Move { view: View, to: Container, index: Option }, + Move { + view: View, + to: Container, + index: Option, + }, Hide(View), Reveal(View), Toggle(Container), SetBar(Container, BarOrientation), Reset, OpenSettings, + LaunchAgent(crate::agents::AgentKind), + CustomBoot(crate::agents::AgentKind), } /// A small count on an icon; `tone` (the colour of an agent status) makes it stand out. @@ -77,17 +83,17 @@ pub fn view_icon(v: View) -> Icon { View::Hosts => Icon::Server, View::Sessions => Icon::Rows, View::Workspaces => Icon::Layout, - View::Agents => Icon::Sparkle, + View::Agents | View::Codex => Icon::Sparkle, } } /// Paints `v`'s icon in `rect` with `col`. Agents uses the agent mark of the tab rows /// (in its own colour, faded the way `col` is relative to full text). pub fn paint_view_icon(p: &egui::Painter, rect: Rect, v: View, col: Color32) { - if v == View::Agents { + if let Some(kind) = v.agent() { let alpha = (f32::from(col.a()) / 255.0).clamp(0.35, 1.0); let strength = if col == theme::text() { 1.0 } else { 0.7 }; - super::agent_icons::paint(p, rect.shrink(rect.width() * 0.05), crate::agents::AgentKind::Claude, alpha * strength); + super::agent_icons::paint(p, rect.shrink(rect.width() * 0.05), kind, alpha * strength); } else { paint_icon(p, rect, view_icon(v), col); } @@ -100,6 +106,7 @@ pub fn view_action(v: View) -> Action { View::Hosts => Action::SearchHosts, View::Workspaces => Action::FocusWorkspacesView, View::Agents => Action::ToggleAgents, + View::Codex => Action::ToggleCodex, } } @@ -201,7 +208,11 @@ impl Strip<'_> { } resp.context_menu(|ui| self.strip_menu(ui, Some(v), events)); if !dragging { - let tip = if selected { format!("{} (click to fold)", keybindings::hint(v.title(), view_action(v))) } else { keybindings::hint(v.title(), view_action(v)) }; + let tip = if selected { + format!("{} (click to fold)", keybindings::hint(v.title(), view_action(v))) + } else { + keybindings::hint(v.title(), view_action(v)) + }; resp.on_hover_text(tip); } } @@ -210,6 +221,10 @@ impl Strip<'_> { fn strip_menu(&self, ui: &mut Ui, view: Option, events: &mut Vec) { ui.set_min_width(200.0); if let Some(v) = view { + if let Some(kind) = v.agent() { + agent_boot_menu(ui, kind, events); + ui.separator(); + } ui.menu_button("Move to", |ui| { for c in Container::ALL { if ui.add_enabled(c != self.container, egui::Button::new(c.title())).clicked() { @@ -249,6 +264,17 @@ impl Strip<'_> { } } +fn agent_boot_menu(ui: &mut Ui, kind: crate::agents::AgentKind, events: &mut Vec) { + if ui.button(format!("Launch {}", kind.name())).clicked() { + events.push(ViewEvent::LaunchAgent(kind)); + ui.close(); + } + if ui.button("Custom Boot…").clicked() { + events.push(ViewEvent::CustomBoot(kind)); + ui.close(); + } +} + /// A gear at the bottom of the primary strip. pub fn settings_button(ui: &mut Ui, strip: Rect, events: &mut Vec) { let r = Rect::from_min_size(Pos2::new(strip.center().x - ITEM / 2.0, strip.bottom() - 8.0 - ITEM), Vec2::splat(ITEM)); @@ -282,7 +308,10 @@ fn badge_width(p: &egui::Painter, b: Badge) -> f32 { } fn paint_badge(p: &egui::Painter, c: Pos2, b: Badge, alpha: f32, inline: bool) { - let (fill, fg) = match b.tone { Some(c) => (c, theme::bg()), None => (theme::surface_3(), theme::text_dim()) }; + let (fill, fg) = match b.tone { + Some(c) => (c, theme::bg()), + None => (theme::surface_3(), theme::text_dim()), + }; let galley = p.layout_no_wrap(badge_text(b), FontId::proportional(9.0), fg.gamma_multiply(alpha)); let pill = Rect::from_center_size(c, Vec2::new(badge_width(p, b), 15.0)); // A ring in the strip's colour keeps it apart from the glyph under it. @@ -344,9 +373,21 @@ impl DropTargets { for (c, zone, preview) in &edges { let hot = matches!(spot, Some(Spot::Edge(t, _)) if t == *c); let a = ctx.animate_bool_with_time(Id::new(("view_drop_edge", *c)), hot, theme::anim_time(0.14)); - p.rect(*zone, CornerRadius::same(8), theme::accent().gamma_multiply(0.05), Stroke::new(1.0, theme::accent().gamma_multiply(0.22)), egui::StrokeKind::Inside); + p.rect( + *zone, + CornerRadius::same(8), + theme::accent().gamma_multiply(0.05), + Stroke::new(1.0, theme::accent().gamma_multiply(0.22)), + egui::StrokeKind::Inside, + ); if a > 0.0 { - p.rect(*preview, CornerRadius::same(8), theme::accent().gamma_multiply(0.16 * a), Stroke::new(1.5, theme::accent().gamma_multiply(0.85 * a)), egui::StrokeKind::Inside); + p.rect( + *preview, + CornerRadius::same(8), + theme::accent().gamma_multiply(0.16 * a), + Stroke::new(1.5, theme::accent().gamma_multiply(0.85 * a)), + egui::StrokeKind::Inside, + ); label(&p, preview.center(), c.title(), a); } } @@ -354,7 +395,13 @@ impl DropTargets { Some(Spot::Strip { container, index }) => self.paint_insertion(&p, container, index, view), Some(Spot::Body(c)) => { if let Some((_, r)) = self.bodies.iter().find(|(b, _)| *b == c) { - p.rect(*r, CornerRadius::same(8), theme::accent().gamma_multiply(0.10), Stroke::new(1.5, theme::accent().gamma_multiply(0.7)), egui::StrokeKind::Inside); + p.rect( + *r, + CornerRadius::same(8), + theme::accent().gamma_multiply(0.10), + Stroke::new(1.5, theme::accent().gamma_multiply(0.7)), + egui::StrokeKind::Inside, + ); label(&p, r.center(), &format!("Move to {}", c.title().to_lowercase()), 1.0); } } @@ -437,7 +484,13 @@ impl DropTargets { fn label(p: &egui::Painter, c: Pos2, text: &str, alpha: f32) { let galley = p.layout_no_wrap(text.to_string(), FontId::proportional(12.0), theme::text().gamma_multiply(alpha)); let pill = Rect::from_center_size(c, galley.size() + Vec2::new(20.0, 10.0)); - p.rect(pill, CornerRadius::same(8), theme::surface_2().gamma_multiply(0.95 * alpha), Stroke::new(1.0, theme::border().gamma_multiply(alpha)), egui::StrokeKind::Inside); + p.rect( + pill, + CornerRadius::same(8), + theme::surface_2().gamma_multiply(0.95 * alpha), + Stroke::new(1.0, theme::border().gamma_multiply(alpha)), + egui::StrokeKind::Inside, + ); p.galley(pill.center() - galley.size() / 2.0, galley, theme::text()); } diff --git a/src/ui/agent_icons.rs b/src/ui/agent_icons.rs index 0664a52..f6328e1 100644 --- a/src/ui/agent_icons.rs +++ b/src/ui/agent_icons.rs @@ -1,6 +1,6 @@ -//! Painted marks for AI agents (no bitmap or trademark assets): Claude Code's orange -//! starburst, Codex' hexagon knot and a sparkle for the rest. Drawn with the painter, -//! so they stay crisp at 14–16 px and in every theme. +//! Agent marks shared by the activity strip, launch cards and session rows. Claude's +//! starburst is painted; Codex uses the OpenAI blossom from its official desktop app. +//! The bundled SVG is rendered at the display's pixel scale and tinted for the theme. use std::f32::consts::{PI, TAU}; @@ -31,7 +31,7 @@ pub fn paint(p: &Painter, rect: Rect, kind: AgentKind, alpha: f32) { let center = rect.center(); match kind { AgentKind::Claude => starburst(p, center, size * 0.5, c), - AgentKind::Codex => hex_knot(p, center, size * 0.46, c), + AgentKind::Codex => codex_mark(p, center, size * 0.46, c), _ => sparkle(p, center, size * 0.5, c), } } @@ -52,12 +52,24 @@ fn starburst(p: &Painter, center: Pos2, radius: f32, c: Color32) { p.circle_filled(center, radius * 0.2, c); } -/// A hexagon with an inner ring: a knot-like "⌬". -fn hex_knot(p: &Painter, center: Pos2, radius: f32, c: Color32) { - let w = (radius * 0.2).clamp(1.1, 1.8); - let hex = |r: f32, turn: f32| -> Vec { (0..6).map(|i| center + Vec2::angled(TAU * i as f32 / 6.0 + turn) * r).collect() }; - p.add(Shape::closed_line(hex(radius, PI / 6.0), Stroke::new(w, c))); - p.add(Shape::closed_line(hex(radius * 0.5, 0.0), Stroke::new(w * 0.9, c))); +/// The actual brand mark, shared by the activity strip, session rows and launch cards. +fn codex_mark(p: &Painter, center: Pos2, radius: f32, c: Color32) { + let rect = Rect::from_center_size(center, Vec2::splat(radius * 2.0)); + let pixels = (rect.width() * p.ctx().pixels_per_point()).ceil().max(24.0) as u32; + let key = egui::Id::new(("codex_brand_mark", pixels)); + let cached = p.ctx().data(|d| d.get_temp::(key)); + let texture = cached.unwrap_or_else(|| { + let image = egui_extras::image::load_svg_bytes_with_size( + include_bytes!("../../assets/agents/codex.svg"), + egui::load::SizeHint::Height(pixels), + &Default::default(), + ) + .expect("bundled Codex brand SVG"); + let texture = p.ctx().load_texture(format!("codex-brand-{pixels}"), image, egui::TextureOptions::LINEAR); + p.ctx().data_mut(|d| d.insert_temp(key, texture.clone())); + texture + }); + p.image(texture.id(), rect, Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)), c); } /// A four-point star with a small companion. diff --git a/src/ui/agent_status.rs b/src/ui/agent_status.rs index 61fa063..bce0424 100644 --- a/src/ui/agent_status.rs +++ b/src/ui/agent_status.rs @@ -289,20 +289,27 @@ fn popover_body(ui: &mut egui::Ui, d: &PopoverData<'_>) -> Option ui.add_space(2.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - let jump = egui::Button::new(RichText::new("Jump to tab").size(12.0).color(theme::on_accent())).fill(theme::accent()).corner_radius(CornerRadius::same(6)).min_size(Vec2::new(0.0, 26.0)); + let jump = egui::Button::new(RichText::new("Jump to tab").size(12.0).color(theme::on_accent())) + .fill(theme::accent()) + .corner_radius(CornerRadius::same(6)) + .min_size(Vec2::new(0.0, 26.0)); if ui.add(jump).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { action = Some(PopoverAction::Jump(s.uid)); } let unread = s.state.needs_attention(); - let read = egui::Button::new(RichText::new("Mark as read").size(12.0)).fill(theme::surface_3()).corner_radius(CornerRadius::same(6)).min_size(Vec2::new(0.0, 26.0)); + let read = egui::Button::new(RichText::new("Mark as read").size(12.0)) + .fill(theme::surface_3()) + .corner_radius(CornerRadius::same(6)) + .min_size(Vec2::new(0.0, 26.0)); let resp = ui.add_enabled(unread, read).on_disabled_hover_text("Nothing new in this session"); if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { action = Some(PopoverAction::MarkRead(s.uid)); } }); if !s.hooked && !d.hooks_installed && matches!(s.kind, AgentKind::Claude | AgentKind::Codex) { - let link = ui.add(egui::Button::new(RichText::new("Install hooks for exact status…").size(11.5).color(theme::accent())).frame(false)); - if link.on_hover_text("State is guessed from the terminal; hooks report it exactly").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + let label = if s.kind == AgentKind::Codex { "Set up completion notifications…" } else { "Install hooks for exact status…" }; + let link = ui.add(egui::Button::new(RichText::new(label).size(11.5).color(theme::accent())).frame(false)); + if link.on_hover_text("Configure the agent signals Tether uses to follow this session").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { action = Some(PopoverAction::InstallHooks); } } diff --git a/src/ui/agents_panel.rs b/src/ui/agents_panel.rs index 49144ce..6a6caa6 100644 --- a/src/ui/agents_panel.rs +++ b/src/ui/agents_panel.rs @@ -11,6 +11,124 @@ use crate::procmon::human_mem; use crate::theme; pub const WIDTH: f32 = 300.0; +pub fn default_command(kind: AgentKind) -> &'static str { + kind.id() +} + +pub struct CustomBoot { + pub kind: AgentKind, + command: String, + shell_id: String, + focus: bool, + custom: bool, + project: super::project_picker::ProjectPicker, + pub target: Option, +} + +impl CustomBoot { + pub fn new(kind: AgentKind) -> Self { + let settings = crate::settings::snapshot(); + let shell = settings.agents.launch_shells.get(kind.id()).and_then(|id| crate::pty::shells::find(id)).or_else(crate::pty::shells::default_shell); + Self { + kind, + command: default_command(kind).into(), + shell_id: shell.map(|s| s.id.clone()).unwrap_or_default(), + focus: true, + custom: true, + project: Default::default(), + target: None, + } + } + + pub fn launch(pick: LaunchPick, target: egui_dock::NodePath) -> Self { + let mut boot = Self::new(pick.kind); + boot.command = pick.command; + boot.custom = false; + boot.target = Some(target); + if let Some(shell) = pick.shell_id { + boot.shell_id = shell; + } + boot + } + + pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { + let mut command = None; + let mut close = false; + use super::form::{self, Kind}; + let modal = egui::Modal::new(egui::Id::new("custom_agent_boot")).frame(form::modal_frame()).show(ctx, |ui| { + egui::Frame::new().inner_margin(24).show(ui, |ui| { + ui.set_width(540.0); + ui.horizontal(|ui| { + super::agent_icons::icon(ui, self.kind, 36.0); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label(RichText::new(format!("{} · {}", self.kind.name(), if self.custom { "Custom Boot" } else { "Launch" })).size(19.0).strong()); + ui.label(RichText::new("Your command, in a new terminal session.").size(12.0).color(theme::text_dim())); + }); + }); + ui.add_space(20.0); + let response = form::section( + ui, + Some(super::settings_widgets::Icon::Terminal), + "Launch command", + "Run your local command with its own flags and account setup.", + |ui| { + let width = ui.available_width(); + form::framed(ui, false, |ui| { + ui.add_enabled(self.custom, form::style_edit(egui::TextEdit::singleline(&mut self.command), width).font(egui::TextStyle::Monospace)) + }) + }, + ); + if std::mem::take(&mut self.focus) { + if self.custom { + response.request_focus(); + } else { + self.project.focus(ctx); + } + } + ui.add_space(12.0); + self.project.show(ui); + ui.add_space(12.0); + form::label(ui, "Shell", false); + let shells = crate::pty::shells::detected().unwrap_or_default(); + let shown = shells.iter().find(|s| s.id == self.shell_id).map_or("Default shell", |s| s.name.as_str()); + let shell_response = form::select(ui, ui.id().with("boot_shell"), ui.available_width(), shown, |ui| { + for shell in shells { + if form::option(ui, &shell.name, None, shell.id == self.shell_id).clicked() { + self.shell_id.clone_from(&shell.id); + ui.close(); + } + } + }); + shell_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Terminal shell")); + ui.add_space(6.0); + form::note(ui, "Uses this shell's profiles, functions and aliases. Your command and flags are passed through unchanged.", false); + let valid = !self.command.trim().is_empty() && !self.command.contains(['\r', '\n', '\0']); + ui.add_space(20.0); + form::rule(ui); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if form::button(ui, "Start terminal", Kind::Primary, valid).clicked() || (valid && enter) { + if let Some(cwd) = self.project.directory() { + command = Some(LaunchPick { + kind: self.kind, + command: self.command.trim().to_owned(), + shell_id: Some(self.shell_id.clone()), + cwd: Some(cwd), + }); + close = true; + } + } + if form::button(ui, "Cancel", Kind::Secondary, true).clicked() { + close = true; + } + }); + }); + }); + (command, close || modal.should_close()) + } +} const ROW_H: f32 = 64.0; pub enum PanelAction { @@ -20,15 +138,18 @@ pub enum PanelAction { Launch(LaunchPick), /// Settings › AI agents (hook installer). InstallHooks, + CustomBoot(AgentKind), } /// An agent picked from a "+" menu. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct LaunchPick { pub kind: AgentKind, - pub command: &'static str, - /// Ask for the folder to start in first. - pub pick_folder: bool, + pub command: String, + /// A chosen local shell, including its normal startup profiles. + pub shell_id: Option, + /// A confirmed directory; absent means show the shared launch form first. + pub cwd: Option, } /// "Claude Code" / "Codex" entries for a "+" menu: only agents found on PATH. @@ -42,11 +163,8 @@ pub fn launch_menu(ui: &mut egui::Ui) -> Option { for l in found { ui.horizontal(|ui| { let text = RichText::new(format!("{} {}", l.kind.glyph(), l.kind.name())).color(theme::hue_color(l.kind.hue())); - if ui.button(text).on_hover_text(format!("Run {} in a new default shell\n{}", l.command, l.path.display())).clicked() { - pick = Some(LaunchPick { kind: l.kind, command: l.command, pick_folder: false }); - } - if ui.small_button("in folder…").on_hover_text("Choose the folder to start in").clicked() { - pick = Some(LaunchPick { kind: l.kind, command: l.command, pick_folder: true }); + if ui.button(text).on_hover_text(format!("Choose a project and shell for {}\n{}", l.command, l.path.display())).clicked() { + pick = Some(LaunchPick { kind: l.kind, command: l.command.into(), shell_id: None, cwd: None }); } }); } @@ -76,16 +194,21 @@ pub fn list_height(sessions: &[SessionInfo]) -> f32 { if sessions.is_empty() { EMPTY_H } else { sessions.len() as f32 * (ROW_H + 4.0) } } -const EMPTY_H: f32 = 136.0; +const EMPTY_H: f32 = 172.0; /// The sessions without a header (the sidebar's "Agents" section draws its own). pub fn list(ui: &mut egui::Ui, sessions: &[SessionInfo], now: Instant) -> Option { + list_for(ui, sessions, crate::views::View::Agents, now) +} + +pub fn list_for(ui: &mut egui::Ui, sessions: &[SessionInfo], view: crate::views::View, now: Instant) -> Option { let mut action = None; - if sessions.is_empty() { - return empty_state(ui); + let mut sessions = sessions.iter().filter(|s| view.includes_agent(s.kind)).peekable(); + if sessions.peek().is_none() { + return empty_state(ui, view.agent().unwrap_or(AgentKind::Claude)); } let time = ui.input(|i| i.time); - egui::ScrollArea::vertical().id_salt("agents_list").auto_shrink([false, false]).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt(("agents_list", view)).auto_shrink([false, false]).show(ui, |ui| { for s in sessions { if row(ui, s, now, time) { action = Some(PanelAction::Focus(s.uid)); @@ -96,49 +219,33 @@ pub fn list(ui: &mut egui::Ui, sessions: &[SessionInfo], now: Instant) -> Option action } -/// A card instead of the list: what shows up here, and the two ways to get started. -fn empty_state(ui: &mut egui::Ui) -> Option { - use super::settings_widgets::{Icon, paint_icon}; +fn empty_state(ui: &mut egui::Ui, kind: AgentKind) -> Option { + use super::form::{self, Kind}; let mut action = None; ui.add_space(4.0); egui::Frame::new() .fill(theme::surface_2().gamma_multiply(0.7)) .stroke(egui::Stroke::new(1.0, theme::border().gamma_multiply(0.6))) - .corner_radius(CornerRadius::same(10)) - .inner_margin(egui::Margin::same(12)) + .corner_radius(10) + .inner_margin(12) .show(ui, |ui| { ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let (tile, _) = ui.allocate_exact_size(Vec2::splat(30.0), Sense::hover()); - ui.painter().rect_filled(tile, CornerRadius::same(8), theme::accent_soft()); - paint_icon(ui.painter(), tile.shrink(7.0), Icon::Sparkle, theme::accent()); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 2.0; - ui.label(RichText::new("No agent sessions").size(13.0).color(theme::text())); - ui.label(RichText::new("Agents in local terminals show up here").size(11.0).color(theme::text_faint())); - }); - }); - ui.add_space(10.0); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 6.0; - let width = ui.available_width(); - let wide = |text: RichText, fill| egui::Button::new(text).fill(fill).corner_radius(CornerRadius::same(7)).min_size(Vec2::new(width, 26.0)); - // Claude Code first when several agents are installed. Never waits on the PATH scan. - let found = launch::found().unwrap_or_default(); - let first = found.iter().find(|l| l.kind == AgentKind::Claude).or_else(|| found.first()); - if let Some(l) = first { - let text = RichText::new(format!("{} Launch {}", l.kind.glyph(), l.kind.name())).size(12.0).color(theme::text()); - let tip = format!("Run {} in a new default shell", l.command); - if ui.add(wide(text, theme::accent_soft())).on_hover_text(tip).clicked() { - action = Some(PanelAction::Launch(LaunchPick { kind: l.kind, command: l.command, pick_folder: false })); - } - } - let hooks = RichText::new("Install hooks…").size(12.0).color(theme::text_dim()); - let tip = "Settings › AI agents: exact states (working, waiting, done) from the agents' hooks"; - if ui.add(wide(hooks, theme::surface_3())).on_hover_text(tip).clicked() { - action = Some(PanelAction::InstallHooks); - } - }); + super::agent_icons::icon(ui, kind, 30.0); + ui.add_space(8.0); + ui.label(RichText::new(format!("{} sessions", kind.name())).size(14.0).strong()); + ui.label(RichText::new("Follow activity, approvals and resource use across your local terminals.").size(12.0).color(theme::text_dim())); + ui.add_space(12.0); + if form::button(ui, &format!("Launch {}", kind.name()), Kind::Primary, true).clicked() { + action = Some(PanelAction::Launch(LaunchPick { kind, command: default_command(kind).into(), shell_id: None, cwd: None })); + } + ui.add_space(4.0); + if form::button(ui, "Custom Boot…", Kind::Secondary, true).clicked() { + action = Some(PanelAction::CustomBoot(kind)); + } + ui.add_space(4.0); + if form::button(ui, "Install hooks…", Kind::Ghost, true).clicked() { + action = Some(PanelAction::InstallHooks); + } }); action } @@ -195,7 +302,11 @@ fn hover_text(s: &SessionInfo) -> String { if let Some(m) = &s.message { t.push_str(&format!("\n\n{}", snippet(m, 400))); } - t.push_str(if s.hooked { "\n\nState from hooks" } else { "\n\nState guessed from the terminal (install hooks in Settings → AI agents)" }); + t.push_str(match (s.kind, s.hooked) { + (AgentKind::Codex, true) => "\n\nCompletion from Codex notify; activity from the terminal", + (_, true) => "\n\nState from hooks", + _ => "\n\nState from terminal activity (configure agent signals in Settings)", + }); t.push_str("\nClick to focus the tab"); t } @@ -206,6 +317,42 @@ fn clip(text: &str, width: f32, size: f32) -> String { snippet(text, max) } +#[cfg(test)] +mod custom_boot_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn local_account_flags_pass_through_the_form_without_a_prompt() { + let result = std::sync::Arc::new(std::sync::Mutex::new(None)); + let output = result.clone(); + let mut boot = CustomBoot { + kind: AgentKind::Claude, + command: "claude -p".into(), + shell_id: "powershell".into(), + focus: false, + custom: true, + project: Default::default(), + target: None, + }; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 950.0)).build_ui(move |ui| { + if output.lock().unwrap().is_none() { + let (pick, _) = boot.show(ui.ctx()); + if let Some(pick) = pick { + *output.lock().unwrap() = Some(pick); + } + } + }); + h.run_steps(5); + assert!(h.query_by_label("Initial prompt").is_none()); + h.get_by_label("Start terminal").click(); + h.run_steps(5); + let pick = result.lock().unwrap().take().expect("custom command was accepted"); + assert_eq!(pick.command, "claude -p"); + assert_eq!(pick.shell_id.as_deref(), Some("powershell")); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/attachments.rs b/src/ui/attachments.rs new file mode 100644 index 0000000..2cc1a4c --- /dev/null +++ b/src/ui/attachments.rs @@ -0,0 +1,327 @@ +//! Image attachments shared by clipboard, file picker and drop. The terminal receives +//! a real accessible image path; the UI retains a colored label and a small preview. +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver}; + +use crate::profile::Profile; +use crate::ssh::pool::Pool; +use crate::theme; +use anyhow::{Context, Result, ensure}; +use eframe::egui::{self, RichText}; + +pub struct RemoteTarget { + pub pool: Pool, + pub profile: Profile, + pub runtime: tokio::runtime::Handle, +} + +struct Attachment { + number: usize, + texture: egui::TextureHandle, +} + +struct Prepared { + pixels: image::RgbaImage, + local: PathBuf, + path: String, +} + +#[derive(Default)] +pub struct Attachments { + pub remote: Option, + items: Vec, + pending: Vec>>, + pub notice: Option, + next: usize, + cache: Option>, +} + +const MAX_IMAGE_BYTES: u64 = 20 * 1024 * 1024; + +pub fn is_image(path: &Path) -> bool { + path.extension().and_then(|s| s.to_str()).is_some_and(|s| ["png", "jpg", "jpeg"].iter().any(|e| s.eq_ignore_ascii_case(e))) +} + +fn snapshot(path: &Path, dir: &Path) -> Result { + ensure!(std::fs::metadata(path)?.len() <= MAX_IMAGE_BYTES, "Image exceeds 20 MiB"); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("png"); + let dest = dir.join(format!("{}.{}", uuid::Uuid::new_v4(), ext)); + std::fs::copy(path, &dest)?; + Ok(dest) +} + +fn preview(path: &Path) -> Result { + let mut reader = image::ImageReader::open(path)?.with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(128 * 1024 * 1024); + reader.limits(limits); + Ok(reader.decode()?.thumbnail(240, 160).to_rgba8()) +} + +impl Attachments { + pub fn is_pending(&self) -> bool { + !self.pending.is_empty() + } + fn cache(&mut self) -> Result> { + if self.cache.is_none() { + self.cache = Some(std::sync::Arc::new(tempfile::Builder::new().prefix("tether-images-").tempdir()?)); + } + Ok(self.cache.as_ref().unwrap().clone()) + } + pub fn add(&mut self, paths: Vec, ctx: &egui::Context) { + for path in paths.into_iter().filter(|p| is_image(p)).take(16) { + let cache = match self.cache() { + Ok(cache) => cache, + Err(e) => { + self.notice = Some(e.to_string()); + break; + } + }; + if self.pending.len() + self.items.len() >= 32 { + self.notice = Some("Up to 32 image previews per terminal. Clear previews to add more.".into()); + break; + } + let (tx, rx) = mpsc::channel(); + self.pending.push(rx); + let ctx = ctx.clone(); + let remote = self.remote.as_ref().map(|r| (r.pool.clone(), r.profile.clone(), r.runtime.clone())); + // Decode/copy off the UI thread; uploads use the existing SSH transport. + std::thread::spawn(move || { + let result = snapshot(&path, cache.path()).and_then(|local| { + let pixels = preview(&local)?; + let inserted = if let Some((pool, profile, runtime)) = remote { + runtime.block_on(upload(&pool, &profile, &local))? + } else { + local.display().to_string() + }; + Ok(Prepared { pixels, local, path: inserted }) + }); + let _ = tx.send(result); + ctx.request_repaint(); + }); + } + } + + pub fn paste_image(&mut self, ctx: &egui::Context) -> bool { + let result = (|| -> Result { + let mut clipboard = arboard::Clipboard::new()?; + let data = clipboard.get_image()?; + ensure!(data.bytes.len() as u64 <= 128 * 1024 * 1024, "Clipboard image is too large"); + let image = image::RgbaImage::from_raw(u32::try_from(data.width)?, u32::try_from(data.height)?, data.bytes.into_owned()) + .context("Invalid clipboard image")?; + let dir = self.cache()?; + let path = dir.path().join(format!("{}.png", uuid::Uuid::new_v4())); + image.save(&path)?; + Ok(path) + })(); + match result { + Ok(path) => { + self.add(vec![path], ctx); + true + } + Err(e) => { + self.notice = Some(format!("Could not paste image: {e:#}")); + false + } + } + } + + /// Returns paths only after successful preparation/upload, never a local path to SSH. + pub fn poll(&mut self, ctx: &egui::Context) -> Vec { + let mut paths = Vec::new(); + let mut i = 0; + while i < self.pending.len() { + let result = match self.pending[i].try_recv() { + Ok(result) => result, + Err(mpsc::TryRecvError::Empty) => { + i += 1; + continue; + } + Err(_) => Err(anyhow::anyhow!("Image worker stopped")), + }; + self.pending.remove(i); + match result { + Ok(Prepared { pixels, local, path }) => { + self.next += 1; + let size = [pixels.width() as usize, pixels.height() as usize]; + let image = egui::ColorImage::from_rgba_unmultiplied(size, pixels.as_raw()); + let texture = ctx.load_texture(format!("attachment-{}", local.display()), image, egui::TextureOptions::LINEAR); + self.items.push(Attachment { number: self.next, texture }); + paths.push(PathBuf::from(path)); + } + Err(e) => self.notice = Some(format!("Image attachment failed: {e:#}")), + } + } + if !self.pending.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } else if !paths.is_empty() { + self.notice = None; + } + paths + } + + pub fn bar(&mut self, ui: &mut egui::Ui, rect: egui::Rect) { + ui.scope_builder(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.painter().rect_filled(rect, egui::CornerRadius::same(6), theme::surface()); + if ui.button("+ Image").on_hover_text("Attach PNG or JPEG · You can also paste or drop an image").clicked() { + if let Some(paths) = rfd::FileDialog::new().set_title("Attach images").add_filter("Images", &["png", "jpg", "jpeg"]).pick_files() { + self.add(paths, ui.ctx()); + } + } + if !self.pending.is_empty() { + ui.spinner(); + } + egui::ScrollArea::horizontal().id_salt("image_previews").show(ui, |ui| { + ui.horizontal(|ui| { + for item in &self.items { + let text = RichText::new(format!("[Image {}]", item.number)).color(theme::accent()).strong(); + ui.add(egui::Button::new(text).fill(theme::accent_soft())).on_hover_ui(|ui| { + ui.set_max_width(240.0); + ui.image((item.texture.id(), item.texture.size_vec2())); + }); + } + if !self.items.is_empty() + && ui.small_button("Clear previews").on_hover_text("Hide previews; paths already inserted remain in the terminal").clicked() + { + self.items.clear(); + } + if let Some(notice) = &self.notice { + ui.label(RichText::new(notice).small().color(theme::warn())); + } + }); + }); + }); + } +} + +async fn upload(pool: &Pool, profile: &Profile, local: &Path) -> Result { + use russh_sftp::protocol::{FileAttributes, OpenFlags}; + let conn = pool.get(profile, crate::profile::secret::get(profile.secret_id())).await?; + let sftp = crate::ssh::sftp::Sftp::open(&conn).await?; + let home = sftp.canonicalize(".").await?; + let dir = crate::ssh::sftp::join(&home, &format!(".tether-image-{}", uuid::Uuid::new_v4())); + sftp.raw.mkdir(&dir, FileAttributes { permissions: Some(0o700), ..FileAttributes::empty() }).await?; + let remote = crate::ssh::sftp::join(&dir, local.file_name().unwrap_or_default().to_string_lossy().as_ref()); + let handle = sftp + .raw + .open(&remote, OpenFlags::CREATE | OpenFlags::EXCLUDE | OpenFlags::WRITE, FileAttributes { permissions: Some(0o600), ..FileAttributes::empty() }) + .await? + .handle; + let bytes = tokio::fs::read(local).await?; + let result = async { + for (i, chunk) in bytes.chunks(sftp.write_chunk as usize).enumerate() { + sftp.raw.write(&handle, i as u64 * u64::from(sftp.write_chunk), chunk.to_vec()).await?; + } + anyhow::Ok(()) + } + .await; + let closed = sftp.raw.close(handle).await; + result?; + closed?; + Ok(remote) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(dir: &Path) -> PathBuf { + let path = dir.join("reference.png"); + image::RgbaImage::from_fn(480, 320, |x, y| image::Rgba([(x % 255) as u8, (y % 255) as u8, 120, 255])).save(&path).unwrap(); + path + } + + fn finish(images: &mut Attachments, ctx: &egui::Context) -> Vec { + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut paths = Vec::new(); + while images.is_pending() { + paths.extend(images.poll(ctx)); + assert!(std::time::Instant::now() < until); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + paths + } + + #[test] + fn attachment_preserves_pixels_and_cleans_local_files_when_terminal_closes() { + let dir = tempfile::tempdir().unwrap(); + let source = fixture(dir.path()); + let mut images = Attachments::default(); + let ctx = egui::Context::default(); + images.add(vec![source.clone()], &ctx); + let ready = finish(&mut images, &ctx); + assert_eq!(ready.len(), 1); + assert_eq!(std::fs::read(&source).unwrap(), std::fs::read(&ready[0]).unwrap()); + assert_eq!(images.items[0].texture.size(), [240, 160]); + drop(images); + assert!(!ready[0].exists()); + } + + #[test] + fn corrupt_image_never_reaches_the_terminal() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("bad.png"); + std::fs::write(&source, "not a png").unwrap(); + let ctx = egui::Context::default(); + let mut images = Attachments::default(); + images.add(vec![source], &ctx); + assert!(finish(&mut images, &ctx).is_empty()); + assert!(images.items.is_empty()); + assert!(images.notice.as_deref().unwrap().contains("failed")); + } + + #[tokio::test] + #[ignore = "requires TETHER_IMAGE_E2E_TARGET pointing at an isolated SFTP test server"] + async fn remote_image_roundtrip_and_permissions() { + let target = std::env::var("TETHER_IMAGE_E2E_TARGET").expect("test server target"); + let profile = Profile::parse_quick(&target).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let pool = Pool::new(crate::known_hosts::KnownHosts::load(dir.path().join("known-hosts.json"))); + let conn = pool.get(&profile, Some("tetherpass".into())).await.unwrap(); + let local = fixture(dir.path()); + let remote = upload(&pool, &profile, &local).await.unwrap(); + assert!(remote.contains("/.tether-image-")); + assert!(!remote.contains("Temp")); + let sftp = crate::ssh::sftp::Sftp::open(&conn).await.unwrap(); + let bytes = crate::remote_edit::read_capped(&sftp, &remote, MAX_IMAGE_BYTES).await.unwrap(); + assert_eq!(bytes, std::fs::read(local).unwrap()); + assert_eq!(sftp.stat(&remote).await.unwrap().permissions.unwrap() & 0o777, 0o600); + let parent = crate::ssh::sftp::parent(&remote); + assert_eq!(sftp.stat(&parent).await.unwrap().permissions.unwrap() & 0o777, 0o700); + sftp.raw.remove(&remote).await.unwrap(); + sftp.raw.rmdir(parent).await.unwrap(); + } + + #[test] + #[ignore = "renders attachment and hover preview with a GPU"] + fn image_preview_screenshot() { + use egui_kittest::kittest::Queryable; + let dir = tempfile::tempdir().unwrap(); + let source = fixture(dir.path()); + let mut images = Attachments::default(); + let ctx = egui::Context::default(); + images.add(vec![source], &ctx); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(760.0, 320.0)).wgpu().build_ui(move |ui| { + ui.ctx().all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); + images.poll(ui.ctx()); + ui.heading("Image attachments"); + ui.label("Paste, drop or select an image. Hover its label to preview it."); + let rect = egui::Rect::from_min_size(egui::pos2(10.0, 260.0), egui::vec2(740.0, 34.0)); + images.bar(ui, rect); + }); + for _ in 0..60 { + harness.run_steps(2); + if harness.query_by_label("[Image 1]").is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + harness.get_by_label("[Image 1]").hover(); + harness.run_steps(15); + let output = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/ui-shots"); + std::fs::create_dir_all(&output).unwrap(); + harness.render().unwrap().save(output.join("general-images.png")).unwrap(); + } +} diff --git a/src/ui/blocks.rs b/src/ui/blocks.rs index 3c7af5d..70abf01 100644 --- a/src/ui/blocks.rs +++ b/src/ui/blocks.rs @@ -243,7 +243,11 @@ impl BlocksView { let (o0, o1) = (clip(row_y(out)), clip(row_y(end))); if o1 > o0 { let alpha = if selected { 0.6 } else { 0.22 }; - painter.rect_filled(Rect::from_x_y_ranges(bar_x + 0.5..=bar_x + BAR_W - 0.5, o0..=o1), CornerRadius::same(1), color.gamma_multiply(alpha)); + painter.rect_filled( + Rect::from_x_y_ranges(bar_x + 0.5..=bar_x + BAR_W - 0.5, o0..=o1), + CornerRadius::same(1), + color.gamma_multiply(alpha), + ); } } if y1 > y0 { @@ -400,11 +404,7 @@ fn step(handle: &TerminalHandle, from: u64, up: bool, at_live: bool) -> Option<( let live_top = shell.top_line(0); let ran = |b: &&Block| b.output_line.is_some(); let from = if at_live { blocks.back().map_or(from, |b| b.prompt_line) } else { from }; - let target = if up { - blocks.iter().rev().filter(ran).find(|b| b.prompt_line < from) - } else { - blocks.iter().filter(ran).find(|b| b.prompt_line > from) - }; + let target = if up { blocks.iter().rev().filter(ran).find(|b| b.prompt_line < from) } else { blocks.iter().filter(ran).find(|b| b.prompt_line > from) }; Some((target.map(|b| (b.id, b.prompt_line)), live_top)) } diff --git a/src/ui/detach.rs b/src/ui/detach.rs index d0ec75f..35a3020 100644 --- a/src/ui/detach.rs +++ b/src/ui/detach.rs @@ -105,13 +105,7 @@ impl Detacher { /// Runs once per frame of the main window, after the main dock was shown. /// Renders the detached windows (their tab output is collected into `viewer`), /// moves tabs between windows, and docks windows that rest over the app. - pub fn frame( - &mut self, - ctx: &egui::Context, - main: &mut DockState, - viewer: &mut Viewer, - mini_dock: &mut super::mini_dock::MiniDock, - ) { + pub fn frame(&mut self, ctx: &egui::Context, main: &mut DockState, viewer: &mut Viewer, mini_dock: &mut super::mini_dock::MiniDock) { let (main_inner, main_outer, content, now) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().outer_rect, i.content_rect(), i.time)); let mut drops: Vec<(Option, OutDrop)> = Vec::new(); if let Some(d) = track_drag(ctx, main, Id::new(MAIN_DOCK_ID), &mut self.main_track) { @@ -314,9 +308,7 @@ fn show_window(ctx: &egui::Context, w: &mut DetachedWindow, viewer: &mut Viewer) // Minimize to mini-dock bar button in the top-right header of the detached window. let btn_rect = Rect::from_min_size(Pos2::new(full_rect.right() - 32.0, full_rect.top() + 5.0), Vec2::new(24.0, 20.0)); - let min_resp = ui - .interact(btn_rect, w.dock_id.with("min_to_bar"), egui::Sense::click()) - .on_hover_text("Minimize to mini-bar"); + let min_resp = ui.interact(btn_rect, w.dock_id.with("min_to_bar"), egui::Sense::click()).on_hover_text("Minimize to mini-bar"); let bg = if min_resp.hovered() { theme::accent_soft() } else { theme::surface_2().gamma_multiply(0.85) }; ui.painter().rect_filled(btn_rect, CornerRadius::same(5), bg); ui.painter().rect_stroke(btn_rect, CornerRadius::same(5), Stroke::new(1.0, theme::border().gamma_multiply(0.5)), StrokeKind::Inside); diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 19e6676..c3c161c 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -67,6 +67,7 @@ pub struct ProfileEditor { user_ac: AutoField, host_ac: AutoField, test: TestSlot, + key_search: Option, } pub enum EditorOutcome { @@ -111,6 +112,7 @@ impl ProfileEditor { user_ac: AutoField::new(egui::Id::new("editor_user_ac")), host_ac: AutoField::new(egui::Id::new("editor_host_ac")), test: Arc::new(Mutex::new(TestState::Idle)), + key_search: None, } } @@ -165,7 +167,7 @@ impl ProfileEditor { form::rule(ui); egui::Frame::new().inner_margin(Margin::symmetric(PAD, 16)).show(ui, |ui| self.footer(ui, keys, &mut outcome)); }); - if modal.should_close() && matches!(outcome, EditorOutcome::Open) { + if modal.should_close() && self.key_search.is_none() && matches!(outcome, EditorOutcome::Open) { outcome = EditorOutcome::Cancel; } outcome diff --git a/src/ui/editor/auth.rs b/src/ui/editor/auth.rs index c2c80be..1f4b502 100644 --- a/src/ui/editor/auth.rs +++ b/src/ui/editor/auth.rs @@ -92,6 +92,18 @@ impl ProfileEditor { }, ); } + if form::button(ui, "Search this PC for SSH keys…", Kind::Ghost, true).clicked() { + self.key_search = Some(crate::ui::key_search::KeySearch::new(ui.ctx())); + } + if let Some(search) = &mut self.key_search { + let (picked, close) = search.show(ui.ctx()); + if let Some(path) = picked { + self.profile.auth = AuthMethod::Key { path }; + } + if close { + self.key_search = None; + } + } ui.add_space(10.0); self.secret_field(ui, "Passphrase", "only if the key has one", "Leave blank to be asked when connecting."); } diff --git a/src/ui/form.rs b/src/ui/form.rs index e530b62..978834b 100644 --- a/src/ui/form.rs +++ b/src/ui/form.rs @@ -15,6 +15,15 @@ pub const INPUT_H: f32 = 36.0; pub const GAP: f32 = 12.0; const RADIUS: u8 = 8; +pub fn modal_frame() -> egui::Frame { + egui::Frame::new().fill(theme::surface()).stroke(Stroke::new(1.0, theme::border())).corner_radius(16).shadow(egui::Shadow { + offset: [0, 18], + blur: 48, + spread: 0, + color: theme::shadow(), + }) +} + fn hand(ui: &Ui, resp: &Response) { if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); @@ -304,7 +313,7 @@ pub fn button(ui: &mut Ui, label: &str, kind: Kind, enabled: bool) -> Response { p.rect_filled(rect.translate(Vec2::new(0.0, 1.0)), CornerRadius::same(RADIUS), theme::shadow().gamma_multiply(0.35)); } p.rect(rect, CornerRadius::same(RADIUS), fill.gamma_multiply(alpha), stroke, StrokeKind::Inside); - p.galley(rect.center() - galley.size() / 2.0, galley, text.gamma_multiply(alpha)); + p.galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, text.gamma_multiply(alpha)); resp } diff --git a/src/ui/import_view.rs b/src/ui/import_view.rs index 295a351..4843084 100644 --- a/src/ui/import_view.rs +++ b/src/ui/import_view.rs @@ -5,6 +5,8 @@ use std::path::Path; use eframe::egui::{self, RichText, Ui}; +use super::form::{self, Kind}; +use super::settings_widgets::Icon; use crate::import::{Candidate, Source, bitvise, openssh}; use crate::profile::{Profile, ProfileStore}; use crate::theme; @@ -18,6 +20,10 @@ struct Row { pub struct ImportDialog { rows: Vec, notice: Option, + export: bool, + acknowledged: bool, + scan: Option>, + searched: Vec, } pub enum ImportOutcome { @@ -28,7 +34,7 @@ pub enum ImportOutcome { impl ImportDialog { pub fn new(store: &ProfileStore) -> Self { - let mut dialog = Self { rows: Vec::new(), notice: None }; + let mut dialog = Self { rows: Vec::new(), notice: None, export: false, acknowledged: false, scan: None, searched: Vec::new() }; dialog.add(bitvise::discover(), store); if let Some(path) = openssh::default_path() { dialog.add(openssh::load(&path), store); @@ -43,7 +49,11 @@ impl ImportDialog { if duplicate { continue; } - let exists = c.exists_in(store); + let exists = c.exists_in(store) + || self.rows.iter().any(|r| { + let p = &r.candidate.profile; + p.host.eq_ignore_ascii_case(&c.profile.host) && p.port == c.profile.port && p.user == c.profile.user + }); self.rows.push(Row { candidate: c, selected: !exists, exists }); added += 1; } @@ -77,87 +87,283 @@ impl ImportDialog { } pub fn show(&mut self, ctx: &egui::Context, store: &ProfileStore) -> ImportOutcome { + if let Some(result) = self.scan.as_ref().map(|rx| rx.try_recv()) { + match result { + Ok(found) => { + self.searched = found.searched; + let n = self.add(found.profiles, store); + self.notice = Some(format!( + "Found {n} Bitvise profile(s){}", + if found.limited { " · Search limit reached; use Browse for other folders" } else { "" } + )); + self.scan = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.notice = Some("Search stopped. You can still browse for files manually.".into()); + self.scan = None; + } + Err(_) => { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + } let mut outcome = ImportOutcome::Open; - let modal = egui::Modal::new(egui::Id::new("import_dialog")).show(ctx, |ui| { - ui.set_width(560.0); - ui.heading("Import hosts"); - ui.label(RichText::new("Connection details only — passwords are not imported.").size(12.0).color(theme::text_dim())); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("📁 Bitvise profiles…").clicked() { + let modal = egui::Modal::new(egui::Id::new("import_dialog")).frame(form::modal_frame()).show(ctx, |ui| { + egui::Frame::new().inner_margin(24).show(ui, |ui| { + ui.set_width(640.0_f32.min(ctx.content_rect().width() - 80.0)); + ui.horizontal(|ui| { + form::icon_tile(ui, Icon::Server, theme::accent()); + ui.add_space(6.0); + ui.vertical(|ui| { + ui.label(RichText::new("Import / export machines").size(19.0).strong()); + ui.label(RichText::new("Move your connection setup between devices.").size(12.0).color(theme::text_dim())); + }); + }); + ui.add_space(20.0); + form::segmented(ui, &mut self.export, &[(false, "Import", "Add saved connections"), (true, "Export setup", "Take your machines with you")]); + ui.add_space(16.0); + egui::ScrollArea::vertical() + .id_salt(("setup_body", self.export)) + .max_height((ctx.content_rect().height() - 310.0).clamp(160.0, 480.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + if self.export { + self.export_ui(ui, store); + } else { + self.import_ui(ui, store); + } + }); + if let Some(notice) = &self.notice { + ui.add_space(10.0); + form::note(ui, notice, false); + } + ui.add_space(16.0); + form::rule(ui); + ui.add_space(12.0); + ui.horizontal(|ui| { + let count = if self.export { store.profiles.len() } else { self.rows.iter().filter(|r| r.selected && !r.exists).count() }; + let noun = if count == 1 { "machine" } else { "machines" }; + ui.label( + RichText::new(format!("{count} {noun} {}", if self.export { "in this setup" } else { "selected" })).size(12.0).color(theme::text_dim()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let enabled = count > 0 && (!self.export || self.acknowledged); + let label = if self.export { format!("Export {count} {noun}…") } else { format!("Import {count}") }; + if form::button(ui, &label, Kind::Primary, enabled).clicked() { + if self.export { + self.save_export(store); + } else { + outcome = + ImportOutcome::Import(self.rows.iter().filter(|r| r.selected && !r.exists).map(|r| r.candidate.profile.clone()).collect()); + } + } + if form::button(ui, if self.export { "Close" } else { "Cancel" }, Kind::Secondary, true).clicked() { + outcome = ImportOutcome::Cancel; + } + }); + }); + }); + }); + if modal.should_close() && matches!(outcome, ImportOutcome::Open) { + outcome = ImportOutcome::Cancel; + } + outcome + } + + fn import_ui(&mut self, ui: &mut Ui, store: &ProfileStore) { + form::section(ui, Some(Icon::Folder), "Choose a source", "Bring in a Tether setup or connections from another SSH client.", |ui| { + ui.columns(3, |columns| { + if source_button(&mut columns[0], Icon::Server, "Tether setup", "Portable .json file").clicked() + && let Some(path) = rfd::FileDialog::new().add_filter("Tether setup", &["json"]).pick_file() + { + self.notice = Some(match crate::import::setup::load(&path) { + Ok(candidates) => { + let n = self.add(candidates, store); + format!("{n} host(s) ready to import") + } + Err(e) => format!("Could not import: {e:#}"), + }); + } + if source_button(&mut columns[1], Icon::Terminal, "Bitvise profiles", "Browse .tlp files").clicked() { self.pick_bitvise(store); } - if ui.button("📄 SSH config…").clicked() { + if source_button(&mut columns[2], Icon::FileType, "OpenSSH config", "Choose a config file").clicked() { self.pick_ssh_config(store); } }); - if let Some(n) = &self.notice { - ui.label(RichText::new(n).size(12.0).color(theme::accent())); - } - ui.add_space(6.0); - self.list(ui); ui.add_space(10.0); - let count = self.rows.iter().filter(|r| r.selected).count(); ui.horizontal(|ui| { - let label = RichText::new(format!("Import {count}")).color(egui::Color32::WHITE); - if ui.add_enabled(count > 0, egui::Button::new(label).fill(theme::accent().gamma_multiply(0.8))).clicked() { - let profiles = self.rows.iter().filter(|r| r.selected).map(|r| r.candidate.profile.clone()).collect(); - outcome = ImportOutcome::Import(profiles); + if form::button(ui, "Search this PC", Kind::Secondary, self.scan.is_none()).clicked() { + let (tx, rx) = std::sync::mpsc::channel(); + self.scan = Some(rx); + let ctx = ui.ctx().clone(); + std::thread::spawn(move || { + let _ = tx.send(crate::import::discovery::scan(crate::import::discovery::roots())); + ctx.request_repaint(); + }); } - if ui.button("Cancel").clicked() { - outcome = ImportOutcome::Cancel; + if self.scan.is_some() { + ui.spinner(); } + ui.label(RichText::new("Find Bitvise profiles in common locations").size(11.5).color(theme::text_faint())); }); + if !self.searched.is_empty() { + ui.collapsing("Searched locations", |ui| { + for path in &self.searched { + ui.add(egui::Label::new(RichText::new(path.display().to_string()).size(11.0).color(theme::text_dim())).wrap()); + } + }); + } }); - if modal.should_close() && matches!(outcome, ImportOutcome::Open) { - outcome = ImportOutcome::Cancel; + ui.add_space(12.0); + form::section(ui, Some(Icon::Rows), "Choose machines", "Import selected connections. Saved connections are skipped.", |ui| self.list(ui)); + } + + fn export_ui(&mut self, ui: &mut Ui, store: &ProfileStore) { + form::section(ui, Some(Icon::Server), "Your machines", "One portable setup file, ready to import on another device.", |ui| { + if store.profiles.is_empty() { + form::note(ui, "Add a machine before exporting a setup.", false); + } + egui::ScrollArea::vertical().id_salt("export_machines").max_height(135.0).show(ui, |ui| { + for p in &store.profiles { + machine_row(ui, p, None); + } + }); + }); + ui.add_space(12.0); + form::section(ui, Some(Icon::Key), "Connection details only", "Credentials stay on this device.", |ui| { + ui.columns(2, |columns| { + columns[0].label(RichText::new("INCLUDED").size(10.5).strong().color(theme::accent())); + columns[0].label( + RichText::new( + "Names, IPs / hostnames, ports, +usernames and colors", + ) + .size(12.5) + .color(theme::text_dim()), + ); + columns[1].label(RichText::new("EXCLUDED").size(10.5).strong().color(theme::text_faint())); + columns[1].label( + RichText::new( + "SSH keys, key paths, passwords +and passphrases", + ) + .size(12.5) + .color(theme::text_dim()), + ); + }); + ui.add_space(12.0); + form::rule(ui); + ui.add_space(10.0); + ui.label( + RichText::new("Anyone with this file can import these connection details. Credentials are still required to connect.") + .size(12.0) + .color(theme::text_dim()), + ); + ui.add_space(8.0); + ui.scope(|ui| { + let red = theme::danger(); + let fill = form::input_fill().lerp_to_gamma(red, 0.2); + let w = &mut ui.visuals_mut().widgets; + for state in [&mut w.inactive, &mut w.hovered, &mut w.active] { + state.bg_fill = fill; + state.weak_bg_fill = fill; + state.bg_stroke = egui::Stroke::new(1.0, red.gamma_multiply(0.75)); + state.fg_stroke = egui::Stroke::new(2.0, red); + state.corner_radius = egui::CornerRadius::same(3); + } + ui.checkbox(&mut self.acknowledged, "I understand the file contains connection details"); + }); + }); + } + + fn save_export(&mut self, store: &ProfileStore) { + if let Some(path) = rfd::FileDialog::new().set_file_name("tether-setup.json").add_filter("Tether setup", &["json"]).save_file() { + let result = crate::import::setup::encode(&store.profiles).and_then(|text| std::fs::write(&path, text).map_err(Into::into)); + self.notice = Some(match result { + Ok(()) => format!("Exported to {}", path.display()), + Err(e) => format!("Export failed: {e:#}"), + }); } - outcome } fn list(&mut self, ui: &mut Ui) { if self.rows.is_empty() { - ui.add_space(20.0); + ui.add_space(10.0); ui.vertical_centered(|ui| { - ui.label(RichText::new("Nothing detected automatically").color(theme::text_dim())); - ui.label(RichText::new("Choose Bitvise .tlp files or an SSH config above").size(11.5).color(theme::text_faint())); + form::icon_tile(ui, Icon::Server, theme::text_faint()); + ui.add_space(8.0); + ui.label(RichText::new("Ready to bring your machines over").size(13.0).strong()); + ui.label(RichText::new("Choose a file above or search this PC to get started.").size(12.0).color(theme::text_dim())); }); - ui.add_space(20.0); + ui.add_space(12.0); return; } ui.horizontal(|ui| { - if ui.small_button("Select all").clicked() { - self.rows.iter_mut().for_each(|r| r.selected = true); + if ui.small_button("Select new").clicked() { + self.rows.iter_mut().for_each(|r| r.selected = !r.exists); } if ui.small_button("Select none").clicked() { self.rows.iter_mut().for_each(|r| r.selected = false); } }); - egui::ScrollArea::vertical().max_height(340.0).auto_shrink([false, true]).show(ui, |ui| { - for source in [Source::Bitvise, Source::OpenSsh] { - if !self.rows.iter().any(|r| r.candidate.source == source) { - continue; - } - ui.add_space(4.0); - ui.label(RichText::new(source.label().to_uppercase()).size(10.5).strong().color(theme::text_faint())); - for row in self.rows.iter_mut().filter(|r| r.candidate.source == source) { + ui.add_space(6.0); + for source in [Source::Tether, Source::Bitvise, Source::OpenSsh] { + if !self.rows.iter().any(|r| r.candidate.source == source) { + continue; + } + ui.add_space(4.0); + ui.label(RichText::new(source.label().to_uppercase()).size(10.5).strong().color(theme::text_faint())); + for row in self.rows.iter_mut().filter(|r| r.candidate.source == source) { + egui::Frame::new().fill(form::input_fill()).corner_radius(8).inner_margin(10).show(ui, |ui| { + ui.set_width(ui.available_width()); ui.horizontal(|ui| { - ui.checkbox(&mut row.selected, ""); - let p = &row.candidate.profile; - ui.label(RichText::new(p.display_name()).strong()); - ui.label(RichText::new(p.address()).size(12.0).color(theme::text_dim())); - if row.exists { - ui.label(RichText::new("already saved").size(11.0).color(theme::warn())); - } - }) - .response - .on_hover_text(origin_label(&row.candidate.origin)); - } + ui.add_enabled(!row.exists, egui::Checkbox::new(&mut row.selected, "")); + ui.vertical(|ui| { + machine_row(ui, &row.candidate.profile, row.exists.then_some("Already saved")); + ui.add( + egui::Label::new(RichText::new(row.candidate.origin.display().to_string()).size(10.5).color(theme::text_faint())).truncate(), + ) + .on_hover_text(origin_label(&row.candidate.origin)); + }); + }); + }); + ui.add_space(4.0); } - }); + } } } +fn source_button(ui: &mut Ui, icon: Icon, title: &str, hint: &str) -> egui::Response { + let width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 78.0), egui::Sense::click()); + let fill = if response.hovered() { theme::accent_soft() } else { form::input_fill() }; + ui.painter().rect(rect, 9, fill, egui::Stroke::new(1.0, theme::border()), egui::StrokeKind::Inside); + super::settings_widgets::paint_icon( + ui.painter(), + egui::Rect::from_min_size(rect.min + egui::vec2(12.0, 12.0), egui::vec2(18.0, 18.0)), + icon, + theme::accent(), + ); + ui.painter().text(rect.min + egui::vec2(12.0, 39.0), egui::Align2::LEFT_TOP, title, egui::FontId::proportional(12.5), theme::text()); + ui.painter().text(rect.min + egui::vec2(12.0, 57.0), egui::Align2::LEFT_TOP, hint, egui::FontId::proportional(10.5), theme::text_faint()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + response.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +fn machine_row(ui: &mut Ui, p: &Profile, badge: Option<&str>) { + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.0, theme::hue_color(p.hue)); + ui.label(RichText::new(p.display_name()).size(12.5).strong()); + if let Some(badge) = badge { + ui.label(RichText::new(badge).size(10.5).color(theme::text_faint())); + } + }); + ui.label(RichText::new(p.address()).size(11.5).color(theme::text_dim())); + ui.add_space(5.0); +} + fn origin_label(p: &Path) -> String { format!("From {}", p.display()) } diff --git a/src/ui/key_manager.rs b/src/ui/key_manager.rs index 6fec7a3..0b7b7bd 100644 --- a/src/ui/key_manager.rs +++ b/src/ui/key_manager.rs @@ -102,6 +102,7 @@ pub struct KeyManager { /// Show / hide state of the passphrase fields in the inline forms. show_pass: bool, opened_at: Option, + key_search: Option, } impl KeyManager { @@ -116,6 +117,7 @@ impl KeyManager { install_host: None, show_pass: false, opened_at: None, + key_search: None, } } @@ -171,7 +173,16 @@ impl KeyManager { }); }); }); - if modal.should_close() && matches!(outcome, KeyOutcome::Open) && self.pending.is_none() { + if let Some(search) = &mut self.key_search { + let (path, close) = search.show(ctx); + if close { + self.key_search = None; + } + if let Some(path) = path { + self.import_path(&path, keys, &mut outcome); + } + } + if modal.should_close() && self.key_search.is_none() && matches!(outcome, KeyOutcome::Open) && self.pending.is_none() { outcome = KeyOutcome::Close; } outcome @@ -203,6 +214,9 @@ impl KeyManager { ); ui.add_space(6.0); } + if ui.button("Search this PC for SSH keys…").clicked() { + self.key_search = Some(super::key_search::KeySearch::new(ui.ctx())); + } egui::ScrollArea::vertical().id_salt("key_list").auto_shrink([false, false]).show(ui, |ui| { if keys.keys.is_empty() { ui.add_space(24.0); @@ -230,7 +244,11 @@ impl KeyManager { fn import(&mut self, keys: &mut KeyStore, outcome: &mut KeyOutcome) { let Some(path) = rfd::FileDialog::new().set_title("Import private key").pick_file() else { return }; - match keys.import_file(&path) { + self.import_path(&path, keys, outcome); + } + + fn import_path(&mut self, path: &std::path::Path, keys: &mut KeyStore, outcome: &mut KeyOutcome) { + match keys.import_file(path) { Ok(meta) => { self.select(meta.id); self.message = Some((true, format!("Imported “{}”", meta.name))); diff --git a/src/ui/key_search.rs b/src/ui/key_search.rs new file mode 100644 index 0000000..bf7f3ed --- /dev/null +++ b/src/ui/key_search.rs @@ -0,0 +1,96 @@ +//! Explicitly pick a discovered key; discovery never imports or changes credentials. +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver}; + +use crate::import::discovery::{self, Found}; +use crate::theme; +use eframe::egui::{self, RichText}; + +pub struct KeySearch { + rx: Receiver, + found: Option, + filter: String, +} + +impl KeySearch { + pub fn new(ctx: &egui::Context) -> Self { + let (tx, rx) = mpsc::channel(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(discovery::scan(discovery::roots())); + ctx.request_repaint(); + }); + Self { rx, found: None, filter: String::new() } + } + + pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { + if self.found.is_none() { + match self.rx.try_recv() { + Ok(found) => self.found = Some(found), + Err(mpsc::TryRecvError::Disconnected) => self.found = Some(Found::default()), + Err(_) => ctx.request_repaint_after(std::time::Duration::from_millis(100)), + } + } + let mut picked = None; + let mut close = false; + let modal = egui::Modal::new(egui::Id::new("key_discovery")).show(ctx, |ui| { + ui.set_width(560.0); + ui.heading("Find an SSH key on this PC"); + ui.label("Searches .ssh, Bitvise locations, Documents, Desktop and Downloads. Select a file to use or import it manually."); + ui.label( + RichText::new("Bitvise's encrypted internal key store must be exported from Bitvise as an OpenSSH key first.") + .size(12.0) + .color(theme::text_dim()), + ); + ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text("Filter by file name or location").desired_width(f32::INFINITY)); + if let Some(found) = &self.found { + if found.limited { + ui.label(RichText::new("Search limit reached. Browse to search another location.").color(theme::warn())); + } + if found.keys.is_empty() { + ui.label("No supported private key files found."); + } + let filter = self.filter.to_lowercase(); + egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for path in &found.keys { + if !path.to_string_lossy().to_lowercase().contains(&filter) { + continue; + } + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(path.file_name().unwrap_or_default().to_string_lossy()).strong()); + if ui.button("Select").clicked() { + picked = Some(path.clone()); + close = true; + } + }); + ui.label(RichText::new(path.display().to_string()).size(11.0).color(theme::text_dim())); + }); + } + }); + ui.collapsing("Searched locations", |ui| { + for path in &found.searched { + ui.label(path.display().to_string()); + } + }); + } else { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Searching…"); + }); + } + ui.horizontal(|ui| { + if ui.button("Browse…").clicked() { + if let Some(path) = rfd::FileDialog::new().set_title("Choose an OpenSSH private key").pick_file() { + picked = Some(path); + close = true; + } + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + (picked, close || modal.should_close()) + } +} diff --git a/src/ui/keys.rs b/src/ui/keys.rs index ad3c7b9..0a99a97 100644 --- a/src/ui/keys.rs +++ b/src/ui/keys.rs @@ -45,6 +45,9 @@ fn ctrl_byte(key: Key) -> Option { /// Bytes for a key press, or `None` when the key is handled as text / clipboard. pub fn encode(key: Key, m: Modifiers, app_cursor: bool) -> Option> { let bytes = match key { + // Meta+Enter is understood as an inserted newline by agent TUIs. Shell + // integration binds the same sequence to each shell's multiline editor. + Key::Enter if m.shift && !m.ctrl && !m.alt => b"\x1b\r".to_vec(), Key::Enter => b"\r".to_vec(), Key::Backspace if m.ctrl => vec![0x08], Key::Backspace => vec![0x7f], @@ -98,6 +101,9 @@ pub fn encode_text(text: &str, alt: bool) -> Vec { /// rest as typed commands. pub fn encode_paste(text: &str, bracketed: bool) -> Vec { let normalized: String = text.replace("\r\n", "\r").replace('\n', "\r").chars().filter(|&c| c == '\r' || c == '\t' || !c.is_control()).collect(); + if normalized.is_empty() { + return Vec::new(); + } if bracketed { format!("\x1b[200~{normalized}\x1b[201~").into_bytes() } else { normalized.into_bytes() } } @@ -118,6 +124,12 @@ mod tests { assert_eq!(encode(Key::F5, NONE, false).unwrap(), b"\x1b[15~"); } + #[test] + fn shift_enter_does_not_submit() { + assert_eq!(encode(Key::Enter, Modifiers::SHIFT, false).unwrap(), b"\x1b\r"); + assert_eq!(encode(Key::Enter, NONE, false).unwrap(), b"\r"); + } + #[test] fn arrows_respect_application_cursor_mode() { assert_eq!(encode(Key::ArrowUp, NONE, false).unwrap(), b"\x1b[A"); @@ -136,6 +148,8 @@ mod tests { #[test] fn paste_normalizes_newlines_and_brackets() { + assert!(encode_paste("", true).is_empty(), "an image-only drop must not trigger the CLI's native clipboard paste"); + assert!(encode_paste("", false).is_empty()); assert_eq!(encode_paste("a\r\nb\nc", false), b"a\rb\rc"); assert_eq!(encode_paste("x", true), b"\x1b[200~x\x1b[201~"); assert_eq!(encode_paste("a\x1b[201~rm -rf ~\tb\x07", true), b"\x1b[200~a[201~rm -rf ~\tb\x1b[201~"); diff --git a/src/ui/layout_settings.rs b/src/ui/layout_settings.rs index 57ca86f..db8ef33 100644 --- a/src/ui/layout_settings.rs +++ b/src/ui/layout_settings.rs @@ -99,7 +99,12 @@ fn view_row(ui: &mut Ui, l: &mut LayoutSettings, v: View) { ui.label(egui::RichText::new(v.title()).size(13.5).color(theme::text())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut pick = place; - let options = [(Place::In(Container::Primary), "Primary"), (Place::In(Container::Secondary), "Secondary"), (Place::In(Container::Panel), "Panel"), (Place::Hidden, "Hidden")]; + let options = [ + (Place::In(Container::Primary), "Primary"), + (Place::In(Container::Secondary), "Secondary"), + (Place::In(Container::Panel), "Panel"), + (Place::Hidden, "Hidden"), + ]; w::segmented(ui, &mut pick, &options); if pick != place { match pick { @@ -177,7 +182,16 @@ fn preview(ui: &mut Ui, l: &LayoutSettings) { side_preview(&p, &mut body, l.views.get(c), side == Side::Right, &anim, c); } let panel = &l.views.panel; - let panel_h = anim("panel", if panel.is_empty() { 0.0 } else if panel.collapsed { 11.0 } else { body.height() * 0.36 }); + let panel_h = anim( + "panel", + if panel.is_empty() { + 0.0 + } else if panel.collapsed { + 11.0 + } else { + body.height() * 0.36 + }, + ); if panel_h > 0.5 { let r = Rect::from_min_max(Pos2::new(body.left(), body.bottom() - panel_h), body.max); body.max.y = r.top() - 3.0; @@ -254,8 +268,16 @@ fn content_lines(p: &egui::Painter, r: Rect, view: Option) { p.rect_filled(row, CornerRadius::same(2), theme::surface_3()); p.rect_filled(Rect::from_min_size(row.min, Vec2::new(2.0, row.height())), CornerRadius::same(1), theme::accent()); } - p.rect_filled(Rect::from_center_size(Pos2::new(row.left() + 7.0, row.center().y), Vec2::splat(4.5)), CornerRadius::same(1), theme::hue_color(60.0 * k as f32 + 140.0)); - p.rect_filled(Rect::from_min_size(Pos2::new(row.left() + 13.0, row.center().y - 1.5), Vec2::new(row.width() * 0.5, 3.0)), CornerRadius::same(1), theme::text_faint().gamma_multiply(0.6)); + p.rect_filled( + Rect::from_center_size(Pos2::new(row.left() + 7.0, row.center().y), Vec2::splat(4.5)), + CornerRadius::same(1), + theme::hue_color(60.0 * k as f32 + 140.0), + ); + p.rect_filled( + Rect::from_min_size(Pos2::new(row.left() + 13.0, row.center().y - 1.5), Vec2::new(row.width() * 0.5, 3.0)), + CornerRadius::same(1), + theme::text_faint().gamma_multiply(0.6), + ); y += 11.0; } else { let line = Rect::from_min_size(Pos2::new(r.left() + 5.0, y), Vec2::new((r.width() - 10.0) * [0.8, 0.6, 0.7, 0.5, 0.65][k], 3.5)); @@ -267,7 +289,10 @@ fn content_lines(p: &egui::Painter, r: Rect, view: Option) { /// Two panes, the left focused; tab strips unless vertical tabs are on screen. fn panes(p: &egui::Painter, body: Rect, l: &LayoutSettings, anim: &dyn Fn(&str, f32) -> f32) { - let panes = [Rect::from_min_max(body.min, Pos2::new(body.center().x - 1.5, body.bottom())), Rect::from_min_max(Pos2::new(body.center().x + 1.5, body.top()), body.max)]; + let panes = [ + Rect::from_min_max(body.min, Pos2::new(body.center().x - 1.5, body.bottom())), + Rect::from_min_max(Pos2::new(body.center().x + 1.5, body.top()), body.max), + ]; let dim = anim("dim", if l.dim_inactive_panes { l.dim_amount() } else { 0.0 }); let strips = anim("strips", if l.tab_bars_hidden() { 0.0 } else { 11.0 }); for (k, pane) in panes.into_iter().enumerate() { @@ -287,7 +312,11 @@ fn panes(p: &egui::Painter, body: Rect, l: &LayoutSettings, anim: &dyn Fn(&str, } let wfrac = [0.5, 0.75, 0.35, 0.6][line]; let col = if line == 0 { theme::accent() } else { theme::text_dim() }; - p.rect_filled(Rect::from_min_size(Pos2::new(content.left() + 6.0, y), Vec2::new(content.width() * wfrac * 0.8, 3.0)), CornerRadius::same(1), col.gamma_multiply(0.6)); + p.rect_filled( + Rect::from_min_size(Pos2::new(content.left() + 6.0, y), Vec2::new(content.width() * wfrac * 0.8, 3.0)), + CornerRadius::same(1), + col.gamma_multiply(0.6), + ); } if k == 0 { p.rect_stroke(content, CornerRadius::same(4), Stroke::new(1.0, theme::accent().gamma_multiply(0.7)), egui::StrokeKind::Inside); diff --git a/src/ui/mini_dock.rs b/src/ui/mini_dock.rs index 6cff17c..2272b27 100644 --- a/src/ui/mini_dock.rs +++ b/src/ui/mini_dock.rs @@ -6,10 +6,7 @@ //! bar, or by dragging them onto the mini-dock bar. The bar itself can be dragged and //! snapped ("juntable") to any of the four window edges (Bottom, Top, Left, Right). -use eframe::egui::{ - self, Align2, Color32, CornerRadius, FontId, Id, LayerId, Mesh, Order, Painter, Pos2, Rect, - Sense, Stroke, StrokeKind, Vec2, pos2, vec2, -}; +use eframe::egui::{self, Align2, Color32, CornerRadius, FontId, Id, LayerId, Mesh, Order, Painter, Pos2, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; use egui_dock::{DockState, Node, Surface, SurfaceIndex}; use super::tabs::Tab; @@ -138,21 +135,9 @@ impl MiniDock { }; let side = settings::snapshot().layout.mini_bar_side; let content = ctx.content_rect(); - let dock_rect = self - .last_bar_rect - .map(|r| Rect::from_center_size(r.center(), vec2(36.0, 28.0))) - .unwrap_or_else(|| default_dock_slot(content, side)); + let dock_rect = self.last_bar_rect.map(|r| Rect::from_center_size(r.center(), vec2(36.0, 28.0))).unwrap_or_else(|| default_dock_slot(content, side)); let now = ctx.input(|i| i.time); - self.anims.push(GenieAnim { - window_rect: from_rect, - dock_rect, - side, - minimizing: true, - start_time: now, - hue, - icon, - title, - }); + self.anims.push(GenieAnim { window_rect: from_rect, dock_rect, side, minimizing: true, start_time: now, hue, icon, title }); self.minimized.push(MinimizedWindow { id, tabs, restore_rect: from_rect }); ctx.request_repaint(); } @@ -195,16 +180,7 @@ impl MiniDock { .or_else(|| self.last_bar_rect.map(|r| Rect::from_center_size(r.center(), vec2(36.0, 28.0)))) .unwrap_or_else(|| default_dock_slot(ctx.content_rect(), side)); let now = ctx.input(|i| i.time); - self.anims.push(GenieAnim { - window_rect: target_rect, - dock_rect, - side, - minimizing: false, - start_time: now, - hue, - icon, - title, - }); + self.anims.push(GenieAnim { window_rect: target_rect, dock_rect, side, minimizing: false, start_time: now, hue, icon, title }); let surf = dock.add_window(win.tabs); if let Some(state) = dock.get_window_state_mut(surf) { state.set_position(target_rect.min).set_size(target_rect.size()); @@ -234,42 +210,39 @@ impl MiniDock { let header_h = 28.0; let ctrl_size = vec2(52.0, 22.0); let ctrl_pos = pos2(info.rect.right() - ctrl_size.x - 6.0, info.rect.top() + (header_h - ctrl_size.y) * 0.5); - egui::Area::new(area_id) - .order(Order::Foreground) - .fixed_pos(ctrl_pos) - .show(ctx, |ui| { - let pill = Rect::from_min_size(ctrl_pos, ctrl_size); - ui.painter().rect_filled(pill, CornerRadius::same(6), theme::surface_2().gamma_multiply(0.92)); - ui.painter().rect_stroke(pill, CornerRadius::same(6), Stroke::new(1.0, theme::border().gamma_multiply(0.5)), StrokeKind::Inside); - let min_rect = Rect::from_min_size(pill.min + vec2(3.0, 2.0), vec2(21.0, 18.0)); - let close_rect = Rect::from_min_size(pill.min + vec2(27.0, 2.0), vec2(21.0, 18.0)); - - let min_resp = ui.interact(min_rect, area_id.with("min"), Sense::click()).on_hover_text("Minimize to mini-bar"); - if min_resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - ui.painter().rect_filled(min_rect, CornerRadius::same(4), theme::accent_soft()); - } - let min_col = if min_resp.hovered() { theme::accent() } else { theme::text_dim() }; - let mc = min_rect.center(); - ui.painter().line_segment([mc + vec2(-4.5, 1.0), mc + vec2(4.5, 1.0)], Stroke::new(1.6, min_col)); - if min_resp.clicked() { - to_minimize.push((info.surface, info.rect)); - } + egui::Area::new(area_id).order(Order::Foreground).fixed_pos(ctrl_pos).show(ctx, |ui| { + let pill = Rect::from_min_size(ctrl_pos, ctrl_size); + ui.painter().rect_filled(pill, CornerRadius::same(6), theme::surface_2().gamma_multiply(0.92)); + ui.painter().rect_stroke(pill, CornerRadius::same(6), Stroke::new(1.0, theme::border().gamma_multiply(0.5)), StrokeKind::Inside); + let min_rect = Rect::from_min_size(pill.min + vec2(3.0, 2.0), vec2(21.0, 18.0)); + let close_rect = Rect::from_min_size(pill.min + vec2(27.0, 2.0), vec2(21.0, 18.0)); + + let min_resp = ui.interact(min_rect, area_id.with("min"), Sense::click()).on_hover_text("Minimize to mini-bar"); + if min_resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + ui.painter().rect_filled(min_rect, CornerRadius::same(4), theme::accent_soft()); + } + let min_col = if min_resp.hovered() { theme::accent() } else { theme::text_dim() }; + let mc = min_rect.center(); + ui.painter().line_segment([mc + vec2(-4.5, 1.0), mc + vec2(4.5, 1.0)], Stroke::new(1.6, min_col)); + if min_resp.clicked() { + to_minimize.push((info.surface, info.rect)); + } - let close_resp = ui.interact(close_rect, area_id.with("close"), Sense::click()).on_hover_text("Close window"); - if close_resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - ui.painter().rect_filled(close_rect, CornerRadius::same(4), theme::danger().gamma_multiply(0.24)); - } - let close_col = if close_resp.hovered() { theme::danger() } else { theme::text_dim() }; - let cc = close_rect.center(); - let d = 3.5; - ui.painter().line_segment([cc + vec2(-d, -d), cc + vec2(d, d)], Stroke::new(1.5, close_col)); - ui.painter().line_segment([cc + vec2(d, -d), cc + vec2(-d, d)], Stroke::new(1.5, close_col)); - if close_resp.clicked() { - to_close.push(info.surface); - } - }); + let close_resp = ui.interact(close_rect, area_id.with("close"), Sense::click()).on_hover_text("Close window"); + if close_resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + ui.painter().rect_filled(close_rect, CornerRadius::same(4), theme::danger().gamma_multiply(0.24)); + } + let close_col = if close_resp.hovered() { theme::danger() } else { theme::text_dim() }; + let cc = close_rect.center(); + let d = 3.5; + ui.painter().line_segment([cc + vec2(-d, -d), cc + vec2(d, d)], Stroke::new(1.5, close_col)); + ui.painter().line_segment([cc + vec2(d, -d), cc + vec2(-d, d)], Stroke::new(1.5, close_col)); + if close_resp.clicked() { + to_close.push(info.surface); + } + }); } for (surf, rect) in to_minimize { self.minimize_surface(ctx, dock, surf, rect); @@ -305,10 +278,7 @@ impl MiniDock { if down && !self.dragging_bar { // Check if pointer is dragging a floating window's title bar over the mini-dock bar. - let dragged_surf = floating - .iter() - .find(|f| drop_zone.contains(pos) && f.rect.expand(12.0).contains(pos)) - .map(|f| f.surface); + let dragged_surf = floating.iter().find(|f| drop_zone.contains(pos) && f.rect.expand(12.0).contains(pos)).map(|f| f.surface); self.window_hover_bar = dragged_surf; if dragged_surf.is_some() { let p = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("mini_dock_drop_glow"))); @@ -334,11 +304,7 @@ impl MiniDock { return; } - let bounds = if central_rect.width() > 100.0 && central_rect.height() > 100.0 { - central_rect - } else { - ctx.content_rect() - }; + let bounds = if central_rect.width() > 100.0 && central_rect.height() > 100.0 { central_rect } else { ctx.content_rect() }; let layout = &settings::snapshot().layout; let side = layout.mini_bar_side; let offset = layout.mini_bar_offset; @@ -359,130 +325,120 @@ impl MiniDock { let mut toggle_float_req: Option<(SurfaceIndex, Rect)> = None; let area_id = Id::new("tether_mini_dock_bar"); - egui::Area::new(area_id) - .order(Order::Foreground) - .fixed_pos(bar_rect.min) - .show(ctx, |ui| { - let p = ui.painter(); - p.add(egui::Shadow { offset: [0, 4], blur: 14, spread: 0, color: theme::shadow() }.as_shape(bar_rect, CornerRadius::same(12))); - p.rect_filled(bar_rect, CornerRadius::same(12), theme::surface_2().gamma_multiply(0.96)); - p.rect_stroke(bar_rect, CornerRadius::same(12), Stroke::new(1.0, theme::border()), StrokeKind::Inside); - - // 1. Grip handle for dragging the mini-dock bar to any of the 4 sides. - let handle_rect = if horiz { - Rect::from_min_size(bar_rect.min + vec2(4.0, 4.0), vec2(HANDLE_THICK, bar_rect.height() - 8.0)) - } else { - Rect::from_min_size(bar_rect.min + vec2(4.0, 4.0), vec2(bar_rect.width() - 8.0, HANDLE_THICK)) - }; - let handle_resp = ui - .interact(handle_rect, area_id.with("grip"), Sense::click_and_drag()) - .on_hover_text("Drag to move & snap the mini-bar (Bottom, Top, Left, Right)"); - if handle_resp.hovered() || handle_resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - paint_grip(p, handle_rect, horiz, handle_resp.hovered() || self.dragging_bar); - handle_resp.context_menu(|ui| { - ui.label(egui::RichText::new("Mini-bar position").size(11.0).color(theme::text_faint())); - for s in MiniBarSide::ALL { - if ui.radio(side == s, s.label()).clicked() { - let _ = settings::update(|st| st.layout.mini_bar_side = s); - ui.close(); - } + egui::Area::new(area_id).order(Order::Foreground).fixed_pos(bar_rect.min).show(ctx, |ui| { + let p = ui.painter(); + p.add(egui::Shadow { offset: [0, 4], blur: 14, spread: 0, color: theme::shadow() }.as_shape(bar_rect, CornerRadius::same(12))); + p.rect_filled(bar_rect, CornerRadius::same(12), theme::surface_2().gamma_multiply(0.96)); + p.rect_stroke(bar_rect, CornerRadius::same(12), Stroke::new(1.0, theme::border()), StrokeKind::Inside); + + // 1. Grip handle for dragging the mini-dock bar to any of the 4 sides. + let handle_rect = if horiz { + Rect::from_min_size(bar_rect.min + vec2(4.0, 4.0), vec2(HANDLE_THICK, bar_rect.height() - 8.0)) + } else { + Rect::from_min_size(bar_rect.min + vec2(4.0, 4.0), vec2(bar_rect.width() - 8.0, HANDLE_THICK)) + }; + let handle_resp = ui + .interact(handle_rect, area_id.with("grip"), Sense::click_and_drag()) + .on_hover_text("Drag to move & snap the mini-bar (Bottom, Top, Left, Right)"); + if handle_resp.hovered() || handle_resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + paint_grip(p, handle_rect, horiz, handle_resp.hovered() || self.dragging_bar); + handle_resp.context_menu(|ui| { + ui.label(egui::RichText::new("Mini-bar position").size(11.0).color(theme::text_faint())); + for s in MiniBarSide::ALL { + if ui.radio(side == s, s.label()).clicked() { + let _ = settings::update(|st| st.layout.mini_bar_side = s); + ui.close(); } - }); + } + }); - if handle_resp.drag_started() { - self.dragging_bar = true; + if handle_resp.drag_started() { + self.dragging_bar = true; + } + if self.dragging_bar { + if let Some(ptr) = ui.ctx().pointer_latest_pos() { + let (new_side, new_offset) = snap_bar_to_edge(bounds, ptr); + paint_edge_snap_preview(ui.ctx(), bounds, new_side); + settings::preview(|st| { + st.layout.mini_bar_side = new_side; + st.layout.mini_bar_offset = new_offset; + }); } - if self.dragging_bar { - if let Some(ptr) = ui.ctx().pointer_latest_pos() { - let (new_side, new_offset) = snap_bar_to_edge(bounds, ptr); - paint_edge_snap_preview(ui.ctx(), bounds, new_side); - settings::preview(|st| { - st.layout.mini_bar_side = new_side; - st.layout.mini_bar_offset = new_offset; - }); - } - if ui.input(|i| i.pointer.any_released()) { - self.dragging_bar = false; - let _ = settings::save(); - } + if ui.input(|i| i.pointer.any_released()) { + self.dragging_bar = false; + let _ = settings::save(); } + } + + // 2. Render items (minimized windows first, then active floating windows). + let mut cursor = + if horiz { pos2(handle_rect.right() + 4.0, bar_rect.top() + 5.0) } else { pos2(bar_rect.left() + 5.0, handle_rect.bottom() + 4.0) }; + let step = if horiz { vec2(ITEM_W_HORIZ + 6.0, 0.0) } else { vec2(0.0, ITEM_H_VERT + 6.0) }; + let item_size = if horiz { vec2(ITEM_W_HORIZ, ITEM_H_HORIZ) } else { vec2(ITEM_W_VERT, ITEM_H_VERT) }; - // 2. Render items (minimized windows first, then active floating windows). - let mut cursor = if horiz { - pos2(handle_rect.right() + 4.0, bar_rect.top() + 5.0) - } else { - pos2(bar_rect.left() + 5.0, handle_rect.bottom() + 4.0) + for (idx, win) in self.minimized.iter_mut().enumerate() { + let slot = Rect::from_min_size(cursor, item_size); + cursor += step; + self.slot_rects.push((win.id, slot)); + + let (title, icon, hue, status) = match win.primary_tab_mut() { + Some(tab) => (tab.display_title(), tab.icon().to_string(), tab.profile.hue, tab.status_color()), + None => ("Window".into(), "🗗".into(), 200.0, theme::accent()), }; - let step = if horiz { vec2(ITEM_W_HORIZ + 6.0, 0.0) } else { vec2(0.0, ITEM_H_VERT + 6.0) }; - let item_size = if horiz { vec2(ITEM_W_HORIZ, ITEM_H_HORIZ) } else { vec2(ITEM_W_VERT, ITEM_H_VERT) }; - - for (idx, win) in self.minimized.iter_mut().enumerate() { - let slot = Rect::from_min_size(cursor, item_size); - cursor += step; - self.slot_rects.push((win.id, slot)); - - let (title, icon, hue, status) = match win.primary_tab_mut() { - Some(tab) => (tab.display_title(), tab.icon().to_string(), tab.profile.hue, tab.status_color()), - None => ("Window".into(), "🗗".into(), 200.0, theme::accent()), - }; - let item_id = area_id.with(("min_item", win.id)); - let resp = ui.interact(slot, item_id, Sense::click_and_drag()); - let hover = ui.ctx().animate_bool_with_time(item_id.with("h"), resp.hovered(), theme::anim_time(0.12)); - - paint_bar_chip(ui, slot, horiz, &icon, &title, hue, status, true, hover); - - // Close button on hover for minimized window. - let close_center = if horiz { - pos2(slot.right() - 12.0, slot.center().y) - } else { - pos2(slot.right() - 7.0, slot.top() + 7.0) - }; - let mut closed_clicked = false; - if hover > 0.1 { - let cr = Rect::from_center_size(close_center, vec2(16.0, 16.0)); - let c_resp = ui.interact(cr, item_id.with("close"), Sense::click()).on_hover_text("Close minimized window"); - if c_resp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), theme::danger().gamma_multiply(0.28)); - } - let col = if c_resp.hovered() { theme::danger() } else { theme::text_dim() }; - let d = 3.0; - ui.painter().line_segment([close_center + vec2(-d, -d), close_center + vec2(d, d)], Stroke::new(1.3, col)); - ui.painter().line_segment([close_center + vec2(d, -d), close_center + vec2(-d, d)], Stroke::new(1.3, col)); - if c_resp.clicked() { - closed_clicked = true; - close_min_req = Some(idx); - } + let item_id = area_id.with(("min_item", win.id)); + let resp = ui.interact(slot, item_id, Sense::click_and_drag()); + let hover = ui.ctx().animate_bool_with_time(item_id.with("h"), resp.hovered(), theme::anim_time(0.12)); + + paint_bar_chip(ui, slot, horiz, &icon, &title, hue, status, true, hover); + + // Close button on hover for minimized window. + let close_center = if horiz { pos2(slot.right() - 12.0, slot.center().y) } else { pos2(slot.right() - 7.0, slot.top() + 7.0) }; + let mut closed_clicked = false; + if hover > 0.1 { + let cr = Rect::from_center_size(close_center, vec2(16.0, 16.0)); + let c_resp = ui.interact(cr, item_id.with("close"), Sense::click()).on_hover_text("Close minimized window"); + if c_resp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), theme::danger().gamma_multiply(0.28)); } - - if !closed_clicked { - if resp.clicked() { - restore_req = Some((idx, None)); - } else if resp.dragged() - && let Some(ptr) = ui.ctx().pointer_latest_pos() - && !bar_rect.expand(18.0).contains(ptr) - { - restore_req = Some((idx, Some(ptr - vec2(120.0, 16.0)))); - } + let col = if c_resp.hovered() { theme::danger() } else { theme::text_dim() }; + let d = 3.0; + ui.painter().line_segment([close_center + vec2(-d, -d), close_center + vec2(d, d)], Stroke::new(1.3, col)); + ui.painter().line_segment([close_center + vec2(d, -d), close_center + vec2(-d, d)], Stroke::new(1.3, col)); + if c_resp.clicked() { + closed_clicked = true; + close_min_req = Some(idx); } - resp.on_hover_text(format!("{title} (minimized — click or drag out to restore)")); } - for info in floating { - let slot = Rect::from_min_size(cursor, item_size); - cursor += step; - let item_id = area_id.with(("float_item", info.surface)); - let resp = ui.interact(slot, item_id, Sense::click()); - let hover = ui.ctx().animate_bool_with_time(item_id.with("h"), resp.hovered(), theme::anim_time(0.12)); - - paint_bar_chip(ui, slot, horiz, &info.icon, &info.title, info.hue, info.status, false, hover); + if !closed_clicked { if resp.clicked() { - toggle_float_req = Some((info.surface, info.rect)); + restore_req = Some((idx, None)); + } else if resp.dragged() + && let Some(ptr) = ui.ctx().pointer_latest_pos() + && !bar_rect.expand(18.0).contains(ptr) + { + restore_req = Some((idx, Some(ptr - vec2(120.0, 16.0)))); } - resp.on_hover_text(format!("{} (open — click to minimize)", info.title)); } - }); + resp.on_hover_text(format!("{title} (minimized — click or drag out to restore)")); + } + + for info in floating { + let slot = Rect::from_min_size(cursor, item_size); + cursor += step; + let item_id = area_id.with(("float_item", info.surface)); + let resp = ui.interact(slot, item_id, Sense::click()); + let hover = ui.ctx().animate_bool_with_time(item_id.with("h"), resp.hovered(), theme::anim_time(0.12)); + + paint_bar_chip(ui, slot, horiz, &info.icon, &info.title, info.hue, info.status, false, hover); + if resp.clicked() { + toggle_float_req = Some((info.surface, info.rect)); + } + resp.on_hover_text(format!("{} (open — click to minimize)", info.title)); + } + }); if let Some(idx) = close_min_req { let mut win = self.minimized.remove(idx); @@ -622,10 +578,7 @@ fn paint_genie_funnel(painter: &Painter, anim: &GenieAnim, raw_t: f32) { } fn collect_floating_surfaces(ctx: &egui::Context, dock: &mut DockState) -> Vec { - let indices: Vec = dock - .iter_surfaces_indexed() - .filter_map(|(i, s)| matches!(s, Surface::Window(..)).then_some(i)) - .collect(); + let indices: Vec = dock.iter_surfaces_indexed().filter_map(|(i, s)| matches!(s, Surface::Window(..)).then_some(i)).collect(); let mut out = Vec::new(); for i in indices { let area_id = Id::new(format!("window {i:?}")); @@ -739,17 +692,7 @@ fn paint_grip(p: &Painter, rect: Rect, horiz: bool, hot: bool) { } #[allow(clippy::too_many_arguments)] -fn paint_bar_chip( - ui: &egui::Ui, - slot: Rect, - horiz: bool, - icon: &str, - title: &str, - hue: f32, - status: Color32, - minimized: bool, - hover: f32, -) { +fn paint_bar_chip(ui: &egui::Ui, slot: Rect, horiz: bool, icon: &str, title: &str, hue: f32, status: Color32, minimized: bool, hover: f32) { let p = ui.painter(); let tint = theme::hue_color(hue); let bg = if minimized { @@ -771,8 +714,7 @@ fn paint_bar_chip( let short: String = title.chars().take(16).collect(); let galley = p.layout_no_wrap(short, FontId::proportional(12.0), theme::text()); let text_pos = pos2(tile.right() + 7.0, slot.center().y - galley.size().y * 0.5); - p.with_clip_rect(Rect::from_min_size(pos2(tile.right() + 4.0, slot.top()), vec2(text_max_w, slot.height()))) - .galley(text_pos, galley, theme::text()); + p.with_clip_rect(Rect::from_min_size(pos2(tile.right() + 4.0, slot.top()), vec2(text_max_w, slot.height()))).galley(text_pos, galley, theme::text()); } else { let tile = Rect::from_center_size(slot.center(), vec2(28.0, 28.0)); p.rect_filled(tile, CornerRadius::same(7), tint.gamma_multiply(0.24)); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 44131c6..75e966d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod agent_badge; pub mod agent_icons; pub mod agent_status; pub mod agents_panel; +pub mod attachments; pub mod autocomplete; pub mod backdrop; pub mod blocks; @@ -17,6 +18,7 @@ pub mod file_list; pub mod form; pub mod import_view; pub mod key_manager; +pub mod key_search; pub mod keys; pub mod layout_settings; pub mod loading; @@ -26,6 +28,7 @@ pub mod os_input; pub mod palette; pub mod path_complete; pub mod path_field; +pub mod project_picker; pub mod settings_agents; pub mod settings_keyboard; pub mod settings_sections; diff --git a/src/ui/os_input.rs b/src/ui/os_input.rs index 7f7a55f..2c5c803 100644 --- a/src/ui/os_input.rs +++ b/src/ui/os_input.rs @@ -6,6 +6,49 @@ use std::path::{Path, PathBuf}; use eframe::egui::{self, Pos2}; +/// One clipboard action per key gesture, including delayed Windows key-up events +/// and egui re-running a layout pass with the same input. +#[derive(Default)] +pub(super) struct PasteGuard { + handled: bool, + was_down: bool, + event_frame: Option, + fallback_frame: Option, +} + +impl PasteGuard { + pub fn explicit(&mut self, frame: u64) -> bool { + if self.event_frame == Some(frame) { + return false; + } + self.event_frame = Some(frame); + self.handled = true; + true + } + + pub fn fallback(&mut self, frame: u64, down: bool, released: bool, had_paste: bool) -> bool { + if self.fallback_frame == Some(frame) { + return false; + } + self.fallback_frame = Some(frame); + if down && !self.was_down && !had_paste { + self.handled = false; + } + self.was_down = down; + if had_paste { + self.handled = true; + } + let paste = !self.handled && (down || released); + self.handled |= paste; + // Physical key state can go up before winit delivers KeyUp. Keep the latch + // until that event arrives; otherwise its release pastes the image again. + if released { + self.handled = false; + } + paste + } +} + /// Pointer position (in points, relative to this viewport) for routing an Explorer drop. /// Windows sends no `CursorMoved` during an OLE drag, so the live OS cursor is used there. pub fn drop_pointer_pos(ctx: &egui::Context) -> Option { @@ -81,7 +124,7 @@ pub fn clipboard_has_image() -> bool { #[cfg(not(windows))] pub fn clipboard_has_image() -> bool { - false + arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_image()).is_ok() } /// The clipboard holds plain text (egui-winit already turns it into `Event::Paste`). @@ -94,7 +137,7 @@ pub fn clipboard_has_text() -> bool { #[cfg(not(windows))] pub fn clipboard_has_text() -> bool { - true + arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()).is_ok() } /// Paths of files copied in Explorer (`CF_HDROP`). @@ -122,6 +165,28 @@ fn quote_path(p: &Path) -> String { mod tests { use super::*; + #[test] + fn delayed_release_does_not_repeat_the_first_image() { + let mut guard = PasteGuard::default(); + assert!(guard.fallback(1, true, false, false)); + assert!(!guard.fallback(2, false, false, false)); + assert!(!guard.fallback(3, false, true, false)); + assert!(!guard.fallback(3, false, true, false), "a repeated layout pass is still the same input"); + assert!(guard.fallback(4, true, false, false), "a new intentional paste must work"); + } + + #[test] + fn explicit_paste_and_short_key_presses_are_handled_once() { + let mut guard = PasteGuard::default(); + assert!(guard.explicit(1)); + assert!(!guard.explicit(1)); + assert!(!guard.fallback(1, true, false, true)); + assert!(!guard.fallback(2, false, true, false)); + assert!(guard.fallback(3, false, true, false), "a new press seen only at release is supported"); + assert!(!guard.fallback(3, false, true, false)); + assert!(guard.explicit(4), "separate paste events may attach the same image again"); + } + #[test] fn formats_paths_with_quotes_only_when_needed() { let paths = vec![PathBuf::from(r"C:\img\shot.png"), PathBuf::from(r"C:\My Files\a b.png")]; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a47d87f..1ce573b 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -109,13 +109,16 @@ impl Palette { let screen = ctx.content_rect(); let width = WIDTH.min(screen.width() - 32.0); let id = Id::new("tether_palette"); - let area = egui::Modal::default_area(id).anchor(Align2::CENTER_TOP, Vec2::new(0.0, (screen.height() * 0.12).clamp(24.0, 120.0) - 8.0 * (1.0 - eased))).fade_in(false); + let area = egui::Modal::default_area(id) + .anchor(Align2::CENTER_TOP, Vec2::new(0.0, (screen.height() * 0.12).clamp(24.0, 120.0) - 8.0 * (1.0 - eased))) + .fade_in(false); let dim = if theme::palette().dark { 120 } else { 60 }; let mut run = None; - let modal = egui::Modal::new(id).area(area).backdrop_color(Color32::from_black_alpha(dim).gamma_multiply(eased)).frame(egui::Frame::NONE).show(ctx, |ui| { - ui.multiply_opacity(eased); - run = self.window(ui, width); - }); + let modal = + egui::Modal::new(id).area(area).backdrop_color(Color32::from_black_alpha(dim).gamma_multiply(eased)).frame(egui::Frame::NONE).show(ctx, |ui| { + ui.multiply_opacity(eased); + run = self.window(ui, width); + }); if let Some(cmd) = run { return PaletteOutcome::Run(cmd); } @@ -145,7 +148,12 @@ impl Palette { let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 40.0), Sense::hover()); let p = ui.painter(); p.rect_filled(rect, CornerRadius::same(8), theme::surface_2()); - super::settings_widgets::paint_icon(p, Rect::from_center_size(Pos2::new(rect.left() + 20.0, rect.center().y), Vec2::splat(15.0)), super::settings_widgets::Icon::Search, theme::text_faint()); + super::settings_widgets::paint_icon( + p, + Rect::from_center_size(Pos2::new(rect.left() + 20.0, rect.center().y), Vec2::splat(15.0)), + super::settings_widgets::Icon::Search, + theme::text_faint(), + ); let edit_rect = Rect::from_min_max(Pos2::new(rect.left() + 36.0, rect.top() + 4.0), Pos2::new(rect.right() - 8.0, rect.bottom() - 4.0)); let mut q = self.query.clone(); let edit = ui.put( @@ -288,7 +296,13 @@ fn footer(ui: &mut egui::Ui) { let label = p.text(Pos2::new(chip.right() + 5.0, y), Align2::LEFT_CENTER, what, FontId::proportional(11.0), theme::text_faint()); x = label.right() + 14.0; } - p.text(Pos2::new(rect.right() - 8.0, y), Align2::RIGHT_CENTER, "type words in any order", FontId::proportional(11.0), theme::text_faint().gamma_multiply(0.8)); + p.text( + Pos2::new(rect.right() - 8.0, y), + Align2::RIGHT_CENTER, + "type words in any order", + FontId::proportional(11.0), + theme::text_faint().gamma_multiply(0.8), + ); } #[cfg(test)] diff --git a/src/ui/path_field.rs b/src/ui/path_field.rs index 673146b..13a7631 100644 --- a/src/ui/path_field.rs +++ b/src/ui/path_field.rs @@ -76,6 +76,8 @@ struct Live { pub struct PathField { pub text: String, + /// Launch forms retain edits when another control takes focus. + pub retain_edits: bool, id: Id, style: PathStyle, cycle: Option, @@ -95,6 +97,7 @@ impl PathField { pub fn new(id: Id, style: PathStyle) -> Self { Self { text: String::new(), + retain_edits: false, id, style, cycle: None, @@ -159,7 +162,7 @@ impl PathField { let mut events = Vec::new(); let focused = ui.memory(|m| m.has_focus(self.id)); if !focused && !self.popup_hovered(ui) { - if self.text != cwd { + if !self.retain_edits && self.text != cwd { self.text = cwd.to_string(); } self.cycle = None; diff --git a/src/ui/project_picker.rs b/src/ui/project_picker.rs new file mode 100644 index 0000000..cbc37cc --- /dev/null +++ b/src/ui/project_picker.rs @@ -0,0 +1,219 @@ +//! A directory field with the same completion engine as SFTP, plus local shortcuts. +use super::{ + form, + path_complete::{Candidate, LOCAL}, + path_field::{PathEvent, PathField}, +}; +use crate::{local_projects as projects, settings, theme}; +use eframe::egui::{self, RichText}; +use std::sync::mpsc::{self, Receiver}; + +type ListingResult = (String, Result, String>); + +pub struct ProjectPicker { + field: PathField, + listing: Option<(String, Receiver)>, + next_listing: Option, + project_name: String, + error: Option, +} + +impl Default for ProjectPicker { + fn default() -> Self { + let mut field = PathField::new(egui::Id::new("agent_project_path"), LOCAL); + field.retain_edits = true; + let last = settings::snapshot().projects.last_directory.clone(); + field.text = if last.is_empty() { projects::home().display().to_string() } else { last }; + Self { field, listing: None, next_listing: None, project_name: String::new(), error: None } + } +} + +impl ProjectPicker { + fn request_listing(&mut self, dir: String, ctx: &egui::Context) { + if let Some((current, _)) = &self.listing { + if current != &dir { + self.next_listing = Some(dir); + } + return; + } + let (tx, rx) = mpsc::channel(); + self.listing = Some((dir.clone(), rx)); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let result = std::fs::read_dir(projects::resolve(&dir)) + .map(|entries| { + entries + .take(4096) + .filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .map(|e| Candidate { name: e.file_name().to_string_lossy().into_owned(), is_dir: true }) + .collect() + }) + .map_err(|e| e.to_string()); + let _ = tx.send((dir, result)); + ctx.request_repaint(); + }); + } + pub fn directory(&mut self) -> Option { + let dir = projects::resolve(&self.field.text); + if !self.field.text.trim().is_empty() && dir.is_dir() { + self.error = None; + Some(dir) + } else { + self.error = Some("Choose an existing local folder before starting.".into()); + None + } + } + + pub fn focus(&self, ctx: &egui::Context) { + ctx.memory_mut(|m| m.request_focus(self.field.id())); + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + if let Some((_, rx)) = &self.listing { + match rx.try_recv() { + Ok((dir, result)) => { + match result { + Ok(entries) => self.field.provide_listing(dir, entries), + Err(_) => self.field.listing_failed(&dir), + } + self.listing = None; + } + Err(mpsc::TryRecvError::Disconnected) => self.listing = None, + Err(mpsc::TryRecvError::Empty) => {} + } + } + if self.listing.is_none() + && let Some(dir) = self.next_listing.take() + { + self.request_listing(dir, ui.ctx()); + } + form::section( + ui, + Some(super::settings_widgets::Icon::Folder), + "Project directory", + "Tab completes folders · Shift+Tab cycles backwards · ~ opens home", + |ui| { + let home = projects::home().display().to_string(); + if ui.memory(|m| m.has_focus(self.field.id())) && ui.input(|i| i.key_pressed(egui::Key::Tab)) && self.field.text.starts_with('~') { + self.field.text = projects::resolve(&self.field.text).display().to_string(); + } + let width = ui.available_width(); + let events = self.field.show(ui, &home, None, width, None); + for event in events { + match event { + PathEvent::Navigate(path) => self.field.text = projects::resolve(&path).display().to_string(), + PathEvent::NeedListing(dir) | PathEvent::Prefetch(dir) => self.request_listing(dir, ui.ctx()), + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if form::button(ui, "Browse…", form::Kind::Secondary, true).clicked() + && let Some(dir) = + rfd::FileDialog::new().set_title("Project directory").set_directory(projects::resolve(&self.field.text)).pick_folder() + { + self.field.text = dir.display().to_string(); + self.error = None; + } + ui.add(form::style_edit(egui::TextEdit::singleline(&mut self.project_name).hint_text("Project name (optional)"), 205.0)); + if form::button(ui, "☆ Save", form::Kind::Secondary, true).clicked() + && let Some(dir) = self.directory() + { + let path = dir.display().to_string(); + let name = self.project_name.trim().to_owned(); + if let Err(e) = settings::update(|s| { + s.projects.record(&path, projects::now()); + if let Some(p) = s.projects.entries.iter_mut().find(|p| projects::same_path(&p.path, &path)) { + p.saved = true; + if !name.is_empty() { + p.name.clone_from(&name); + } + } + }) { + self.error = Some(format!("Could not save project: {e}")); + } + } + }); + let prefs = settings::snapshot(); + if !prefs.projects.entries.is_empty() { + ui.add_space(10.0); + form::label(ui, "Saved projects & recent folders", false); + let mut entries: Vec<_> = prefs.projects.entries.iter().collect(); + entries.sort_by_key(|p| (std::cmp::Reverse(p.saved), std::cmp::Reverse(p.last_visit))); + egui::ScrollArea::vertical().id_salt("boot_projects").max_height(116.0).show(ui, |ui| { + for p in entries { + ui.horizontal(|ui| { + let selected = projects::same_path(&self.field.text, &p.path); + let label = format!("{} {}", if p.saved { "★" } else { "↳" }, p.name); + let response = ui.add(egui::Button::selectable(selected, label)).on_hover_text(&p.path); + if response.clicked() { + self.field.text.clone_from(&p.path); + self.project_name.clone_from(&p.name); + self.error = None; + } + response.context_menu(|ui| { + if ui.button(if p.saved { "Unpin project" } else { "Save project" }).clicked() { + let _ = settings::update(|s| { + if let Some(entry) = s.projects.entries.iter_mut().find(|e| e.path == p.path) { + entry.saved = !p.saved; + } + }); + ui.close(); + } + if ui.button("Forget folder").clicked() { + let _ = settings::update(|s| s.projects.entries.retain(|e| e.path != p.path)); + ui.close(); + } + }); + ui.add(egui::Label::new(RichText::new(&p.path).size(11.0).color(theme::text_dim())).truncate()).on_hover_text(&p.path); + }); + } + }); + } + if let Some(error) = &self.error { + form::note(ui, error, true); + } + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn tab_completes_a_real_folder_and_keeps_it_when_focus_moves() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("Project with spaces"); + std::fs::create_dir(&target).unwrap(); + let mut picker = ProjectPicker::default(); + picker.field.text = root.path().join("Pro").display().to_string(); + let actual = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let output = actual.clone(); + let mut initial = true; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 700.0)).build_ui(move |ui| { + if initial { + picker.focus(ui.ctx()); + initial = false; + } + picker.show(ui); + *output.lock().unwrap() = picker.field.text.clone(); + }); + h.run_steps(3); + h.key_press(egui::Key::Tab); + for _ in 0..50 { + h.run_steps(2); + if actual.lock().unwrap().ends_with(std::path::MAIN_SEPARATOR) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let completed = actual.lock().unwrap().clone(); + assert!(projects::same_path(&completed, &target.display().to_string()), "{completed}"); + h.get_all_by_role(egui::accesskit::Role::TextInput).nth(1).unwrap().focus(); + h.run_steps(3); + assert_eq!(*actual.lock().unwrap(), completed); + } +} diff --git a/src/ui/settings_agents.rs b/src/ui/settings_agents.rs index daa8bbe..4e132c4 100644 --- a/src/ui/settings_agents.rs +++ b/src/ui/settings_agents.rs @@ -71,7 +71,7 @@ pub fn show(ui: &mut Ui, s: &mut Settings, st: &mut AgentsState, gap: f32) { }); }); ui.add_space(gap); - w::card(ui, Some("Hooks (exact states)"), |ui| hooks(ui, st)); + w::card(ui, Some("Agent signals"), |ui| hooks(ui, st)); } fn status_text(status: &Status) -> (String, egui::Color32) { @@ -86,8 +86,8 @@ fn status_text(status: &Status) -> (String, egui::Color32) { fn hooks(ui: &mut Ui, st: &mut AgentsState) { ui.label( RichText::new( - "Hooks make Claude Code and Codex report each turn to tether. They only act inside tether terminals \ - (TETHER_SESSION is set there), exit at once elsewhere, and never block the agent.", + "Claude hooks report turn states. Codex notify reports completed turns; terminal activity and process monitoring track ongoing work. \ + These signals only act inside Tether terminals and never block the agent.", ) .size(11.5) .color(theme::text_dim()), diff --git a/src/ui/sftp_dialogs.rs b/src/ui/sftp_dialogs.rs index 4105bb0..d518916 100644 --- a/src/ui/sftp_dialogs.rs +++ b/src/ui/sftp_dialogs.rs @@ -109,37 +109,26 @@ impl SftpView { if conflicts.len() == 1 { ui.heading(format!("Replace “{}”?", conflicts[0])); ui.label( - egui::RichText::new(format!( - "“{}” already exists in the {target_side} folder. Are you sure you want to replace it?", - conflicts[0] - )) - .color(theme::text_dim()), + egui::RichText::new(format!("“{}” already exists in the {target_side} folder. Are you sure you want to replace it?", conflicts[0])) + .color(theme::text_dim()), ); } else { ui.heading(format!("Replace {} existing files?", conflicts.len())); let summary = if items.len() > conflicts.len() { - format!( - "{} of the {} selected items already exist in the {target_side} folder and will be replaced:", - conflicts.len(), - items.len() - ) + format!("{} of the {} selected items already exist in the {target_side} folder and will be replaced:", conflicts.len(), items.len()) } else { format!("The following {} items already exist in the {target_side} folder and will be replaced:", conflicts.len()) }; ui.label(egui::RichText::new(summary).color(theme::text_dim())); ui.add_space(6.0); - egui::Frame::new() - .fill(theme::surface_2()) - .corner_radius(egui::CornerRadius::same(6)) - .inner_margin(8.0) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { - for name in conflicts.iter() { - ui.label(egui::RichText::new(format!("• {name}")).monospace().size(12.0).color(theme::text())); - } - }); + egui::Frame::new().fill(theme::surface_2()).corner_radius(egui::CornerRadius::same(6)).inner_margin(8.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { + for name in conflicts.iter() { + ui.label(egui::RichText::new(format!("• {name}")).monospace().size(12.0).color(theme::text())); + } }); + }); } } Dialog::OpenRefused { name, reason } => { diff --git a/src/ui/sftp_session.rs b/src/ui/sftp_session.rs index 8520ca1..33478c0 100644 --- a/src/ui/sftp_session.rs +++ b/src/ui/sftp_session.rs @@ -309,11 +309,60 @@ impl SftpView { None } + /// Resolve a terminal link using the remote cwd and metadata, never the PC's filesystem. + pub(super) fn resolve_location(&self, path: String, terminal: Option) { + let (Some(sftp), Some(session)) = (self.sftp(), self.session()) else { return }; + let (term, profile, jump, jumping, ctx) = (terminal, self.profile.clone(), self.jump.clone(), self.jumping.clone(), self.ctx.clone()); + let epoch = self.location_epoch.clone(); + let request = epoch.load(Ordering::Relaxed); + jumping.store(true, Ordering::Relaxed); + self.rt.spawn(async move { + let found: Result = async { + let conn = session.pool.get(&profile, secret::get(profile.secret_id())).await?; + let home = sftp.canonicalize(".").await?; + let cwd = if path.starts_with('/') || path.starts_with('~') { + home.clone() + } else if let Some(term) = &term { + let reported = term.shared.lock().ok().and_then(|s| s.parser.callbacks().cwd.clone()); + if let Some(cwd) = reported { + cwd + } else { + let (age_secs, rows, cols) = term.probe_facts(); + crate::ssh::cwd::terminal_cwd(&conn, crate::ssh::cwd::Probe { tag: &term.tag, age_secs, rows, cols }).await? + } + } else { + home.clone() + }; + let resolved = crate::ssh::cwd::resolve_path(&conn, &sftp, &path, &cwd).await?; + let attrs = sftp.stat(&resolved).await?; + let directory = if attrs.is_dir() { resolved } else { crate::ssh::sftp::parent(&resolved) }; + sftp.canonicalize(&directory).await + } + .await; + if epoch.load(Ordering::Relaxed) != request { + return; + } + match found { + Ok(path) => set(&jump, |j| { + if epoch.load(Ordering::Relaxed) == request { + *j = Some(path); + } + }), + Err(e) => session.toasts.error(format!("Could not open path in SFTP: {e:#}")), + } + jumping.store(false, Ordering::Relaxed); + ctx.request_repaint(); + }); + } + /// Asks where the linked terminal is and navigates there (OSC 7 first, then /proc). pub(super) fn jump_to_terminal(&self) { let Some(term) = self.linked_terminal.clone() else { return }; + let epoch = self.location_epoch.clone(); + let request = epoch.fetch_add(1, Ordering::Relaxed).wrapping_add(1); if let Some(cwd) = term.shared.lock().ok().and_then(|s| s.parser.callbacks().cwd.clone()) { set(&self.jump, |j| *j = Some(cwd)); + self.jumping.store(false, Ordering::Relaxed); return; } let Some(session) = self.session() else { return }; @@ -329,8 +378,15 @@ impl SftpView { crate::ssh::cwd::terminal_cwd(&conn, probe).await } .await; + if epoch.load(Ordering::Relaxed) != request { + return; + } match found { - Ok(path) => set(&jump, |j| *j = Some(path)), + Ok(path) => set(&jump, |j| { + if epoch.load(Ordering::Relaxed) == request { + *j = Some(path); + } + }), Err(e) => session.toasts.error(format!("{e:#}")), } jumping.store(false, Ordering::Relaxed); @@ -395,6 +451,7 @@ pub(super) fn humanize(err: &anyhow::Error) -> String { #[cfg(test)] mod tests { use super::*; + use russh_sftp::protocol::Status; fn status(code: StatusCode) -> anyhow::Error { diff --git a/src/ui/sftp_view.rs b/src/ui/sftp_view.rs index b35036f..4d590cb 100644 --- a/src/ui/sftp_view.rs +++ b/src/ui/sftp_view.rs @@ -142,6 +142,9 @@ pub struct SftpView { session: Arc>>, /// Result of a pending terminal-folder lookup, consumed on the UI thread. jump: Arc>>, + /// A terminal link picked before the SFTP channel has finished connecting. + pending_location: Option<(String, Option)>, + location_epoch: Arc, jumping: Arc, /// Files opened with the PC's default app, uploaded on every save. watcher: Watcher, @@ -165,15 +168,7 @@ impl SftpView { let local_history = sftp_history::load_local(&local_history_file, profile.id); let local_known = sftp_history::load_known(&local_known_file, profile.id); let sftp_cfg = settings::get().sftp.clone(); - let initial_local = if sftp_cfg.resume_last_dir { - local_history - .iter() - .map(PathBuf::from) - .find(|p| p.is_dir()) - .unwrap_or(home) - } else { - home - }; + let initial_local = if sftp_cfg.resume_last_dir { local_history.iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or(home) } else { home }; Self { remote: Arc::new(Mutex::new(RemoteState::default())), cache: dir_cache::host_cache(profile.id), @@ -213,6 +208,8 @@ impl SftpView { linked_terminal: None, session: Arc::default(), jump: Arc::default(), + pending_location: None, + location_epoch: Arc::default(), jumping: Arc::default(), watcher: Watcher::default(), open_refused: Arc::default(), @@ -274,6 +271,16 @@ impl SftpView { self.remote.lock().map(|s| matches!(s.link, Link::Ready { .. })).unwrap_or(false) } + pub fn browse_path(&mut self, path: String) { + self.location_epoch.fetch_add(1, Ordering::Relaxed); + if let Ok(mut jump) = self.jump.lock() { + *jump = None; + } + self.pending_location = Some((path, self.linked_terminal.clone())); + self.show_remote = true; + self.ctx.request_repaint(); + } + fn sftp(&self) -> Option> { match &self.remote.lock().ok()?.link { Link::Ready { sftp, .. } => Some(sftp.clone()), @@ -494,13 +501,7 @@ impl SftpView { return; } set(&SFTP_CLIPBOARD, |slot| { - *slot = Some(SftpClipboard { - from: side, - local_dir: self.local_cwd.clone(), - remote_dir: self.remote_cwd(), - names, - os_seq: clipboard_seq(), - }); + *slot = Some(SftpClipboard { from: side, local_dir: self.local_cwd.clone(), remote_dir: self.remote_cwd(), names, os_seq: clipboard_seq() }); }); } @@ -662,6 +663,11 @@ impl SftpView { /// Draws the view; the returned action asks the app to reconnect or close the tab. pub fn ui(&mut self, ui: &mut Ui) -> StatusAction { + if self.is_ready() + && let Some((path, terminal)) = self.pending_location.take() + { + self.resolve_location(path, terminal); + } self.poll_open(); self.poll_local(); if let Some(path) = self.jump.lock().ok().and_then(|mut j| j.take()) { diff --git a/src/ui/sidebar/menu.rs b/src/ui/sidebar/menu.rs index f4ded8e..7a1ab74 100644 --- a/src/ui/sidebar/menu.rs +++ b/src/ui/sidebar/menu.rs @@ -83,7 +83,7 @@ impl Sidebar { ui.separator(); expand_collapse(ui, actions); ui.separator(); - if item(ui, "⬇", "Import hosts…").clicked() { + if item(ui, "⬇", "Import / export setup…").clicked() { actions.push(SidebarAction::Import); } if bound(ui, "🔑", "Key manager", Action::KeyManager).clicked() { @@ -123,7 +123,7 @@ impl Sidebar { if bound(ui, "⚡", "Quick connect", Action::QuickConnect).clicked() { self.focus_quick = true; } - if item(ui, "⬇", "Import hosts…").clicked() { + if item(ui, "⬇", "Import / export setup…").clicked() { actions.push(SidebarAction::Import); } } diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 434df33..8a45406 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -92,13 +92,7 @@ impl Tab { { return Some(cwd); } - let osc_cwd = t - .handle - .shared - .try_lock() - .ok() - .and_then(|s| s.parser.callbacks().cwd.clone()) - .or_else(|| t.remote_cwd().map(str::to_string)); + let osc_cwd = t.handle.shared.try_lock().ok().and_then(|s| s.parser.callbacks().cwd.clone()).or_else(|| t.remote_cwd().map(str::to_string)); if osc_cwd.is_some() { return osc_cwd; } @@ -113,11 +107,7 @@ impl Tab { pub fn spec(&self) -> TabSpec { let cwd = self.current_cwd(); if let Some(shell) = &self.local { - return TabSpec { - title: self.custom_title.clone(), - cwd, - ..TabSpec::new(uuid::Uuid::nil(), TabKind::Local { shell_id: shell.id.clone() }) - }; + return TabSpec { title: self.custom_title.clone(), cwd, ..TabSpec::new(uuid::Uuid::nil(), TabKind::Local { shell_id: shell.id.clone() }) }; } let kind = match self.body { TabBody::Terminal(_) => TabKind::Terminal, @@ -182,7 +172,9 @@ impl Tab { // The agent's own folder (from its hooks) names the project best. let agent_cwd = agent.and_then(|_| crate::agents::hub::agent_cwd(self.uid)); let title_cwd = agent_cwd.as_deref().or(cwd); - return crate::agents::title::tab_title(self.custom_title.as_deref(), agent, title.as_deref(), title_cwd, |raw| pty::tab_title(shell, raw, cwd)); + return crate::agents::title::tab_title(self.custom_title.as_deref(), agent, title.as_deref(), title_cwd, |raw| { + pty::tab_title(shell, raw, cwd) + }); } (TabBody::Terminal(t), None) => t.remote_title(), (TabBody::Sftp(_), _) => None, @@ -286,6 +278,7 @@ pub struct Viewer { pub inputs: Vec<(u64, Vec)>, pub reconnect: Vec, pub open_files: Vec<(u64, Vec)>, + pub open_folders: Vec<(u64, String)>, pub focused_uid: Option, /// Shells picked from a pane's "+" menu, opened in that pane. pub new_local: Vec<(egui_dock::NodePath, &'static ShellSpec)>, @@ -328,6 +321,13 @@ const DOCK_CLOSE_W: f32 = 24.0; /// The tab button under the pointer last frame (0: none): its usage chip shows. static HOVERED_TAB: AtomicU64 = AtomicU64::new(0); +/// A close guard extends slightly into the label. Freeze the existing expansion +/// while crossing it, so both a compact and an expanded tab keep their X in place. +fn usage_hover(rect: egui::Rect, pointer: Option, expanded: bool) -> bool { + let Some(pos) = pointer.filter(|p| rect.contains(*p)) else { return false }; + if pos.x >= rect.right() - DOCK_CLOSE_W - 12.0 { expanded } else { true } +} + /// Per-frame inputs for pane dimming, outlines and focus handling. #[derive(Default, Clone)] pub struct PaneFx { @@ -442,7 +442,8 @@ impl Viewer { fn agent_title(name: &str, kind: AgentKind, state: Option, uid: u64) -> AgentTitle { use egui::text::{LayoutJob, TextFormat}; let mut job = LayoutJob::default(); - let format = |size: f32, color: egui::Color32| TextFormat { font_id: egui::FontId::proportional(size), color, valign: egui::Align::Center, ..Default::default() }; + let format = + |size: f32, color: egui::Color32| TextFormat { font_id: egui::FontId::proportional(size), color, valign: egui::Align::Center, ..Default::default() }; job.append(name, ICON_SLOT, format(12.5, egui::Color32::PLACEHOLDER)); let mut pill = None; if let Some(state) = state @@ -476,11 +477,7 @@ impl egui_dock::TabViewer for Viewer { } let icon = tab.icon(); agent_badge::append_title_mark(&mut job, tab.uid, tab.status_color(), self.time); - let label = if tab.linked_sftp.is_some() { - format!("{icon} {name} · 📁") - } else { - format!("{icon} {name}") - }; + let label = if tab.linked_sftp.is_some() { format!("{icon} {name} · 📁") } else { format!("{icon} {name}") }; RichText::new(label).size(12.5).append_to(&mut job, &egui::Style::default(), egui::FontSelection::Default, egui::Align::Center); if tab.is_local() { usage_chip::append_to_title(&mut job, tab.uid, HOVERED_TAB.load(Ordering::Relaxed) == tab.uid); @@ -501,14 +498,14 @@ impl egui_dock::TabViewer for Viewer { ui.add_space((1.0 - eased) * 10.0); } let is_local = tab.is_local(); + let images_enabled = tab.agent().is_some_and(|a| matches!(a.kind, AgentKind::Claude | AgentKind::Codex)); let tab_uid = tab.uid; let profile = tab.profile.clone(); match &mut tab.body { TabBody::Terminal(view) => { + view.images_enabled = images_enabled; if is_local { - if let Some(cwd) = crate::agents::hub::agent_cwd(tab_uid) - .or_else(|| crate::procmon::usage_for(tab_uid).and_then(|u| u.cwd.clone())) - { + if let Some(cwd) = crate::agents::hub::agent_cwd(tab_uid).or_else(|| crate::procmon::usage_for(tab_uid).and_then(|u| u.cwd.clone())) { view.fallback_cwd = Some(cwd); } } else { @@ -544,6 +541,9 @@ impl egui_dock::TabViewer for Viewer { if !out.input.is_empty() { self.inputs.push((tab_uid, out.input)); } + for folder in out.open_folders { + self.open_folders.push((tab_uid, folder)); + } if !out.open_files.is_empty() { self.open_files.push((tab_uid, out.open_files)); } @@ -566,11 +566,7 @@ impl egui_dock::TabViewer for Viewer { { linked.split_fraction = ((pos.x - avail.left()) / avail.width().max(1.0)).clamp(0.2, 0.8); } - let sep_col = if sep_resp.hovered() || sep_resp.dragged() { - theme::accent() - } else { - theme::border().gamma_multiply(0.55) - }; + let sep_col = if sep_resp.hovered() || sep_resp.dragged() { theme::accent() } else { theme::border().gamma_multiply(0.55) }; ui.painter().vline(sep_rect.center().x, sep_rect.y_range(), egui::Stroke::new(1.5, sep_col)); // 3. Linked SFTP on the right (with draggable header to separate/minimize/close) @@ -582,15 +578,7 @@ impl egui_dock::TabViewer for Viewer { let mut detach_linked = None; let mut minimize_linked = false; - paint_linked_sftp_header( - ui, - tab_uid, - &profile, - header_rect, - &mut close_linked, - &mut detach_linked, - &mut minimize_linked, - ); + paint_linked_sftp_header(ui, tab_uid, &profile, header_rect, &mut close_linked, &mut detach_linked, &mut minimize_linked); let mut sftp_ui = ui.new_child(egui::UiBuilder::new().max_rect(sftp_body_rect).id_salt(("linked_sftp", tab_uid))); sftp_ui.set_clip_rect(sftp_body_rect.intersect(ui.clip_rect())); @@ -613,6 +601,9 @@ impl egui_dock::TabViewer for Viewer { if !out.input.is_empty() { self.inputs.push((tab_uid, out.input)); } + for folder in out.open_folders { + self.open_folders.push((tab_uid, folder)); + } if !out.open_files.is_empty() { self.open_files.push((tab_uid, out.open_files)); } @@ -659,10 +650,11 @@ impl egui_dock::TabViewer for Viewer { fn on_tab_button(&mut self, tab: &mut Tab, response: &egui::Response) { let hovered = HOVERED_TAB.load(Ordering::Relaxed); - if response.hovered() && hovered != tab.uid { + let over = usage_hover(response.rect, response.ctx.pointer_hover_pos(), hovered == tab.uid); + if over && hovered != tab.uid { HOVERED_TAB.store(tab.uid, Ordering::Relaxed); response.ctx.request_repaint(); - } else if !response.hovered() && hovered == tab.uid { + } else if !over && hovered == tab.uid { HOVERED_TAB.store(0, Ordering::Relaxed); response.ctx.request_repaint(); } @@ -754,9 +746,7 @@ fn paint_linked_sftp_header( let buttons_w = 72.0; let drag_rect = egui::Rect::from_min_max(rect.min, egui::pos2((rect.right() - buttons_w).max(rect.left() + 20.0), rect.bottom())); let drag_id = ui.id().with(("linked_sftp_hdr_drag", tab_uid)); - let drag_resp = ui - .interact(drag_rect, drag_id, egui::Sense::click_and_drag()) - .on_hover_text("Linked SFTP · Drag to separate into an independent window"); + let drag_resp = ui.interact(drag_rect, drag_id, egui::Sense::click_and_drag()).on_hover_text("Linked SFTP · Drag to separate into an independent window"); if drag_resp.hovered() || drag_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } @@ -771,11 +761,7 @@ fn paint_linked_sftp_header( let label = format!("📁 SFTP · {}", profile.display_name()); let galley = ui.painter().layout_no_wrap(label, egui::FontId::proportional(11.5), theme::text()); ui.painter().circle_filled(egui::pos2(rect.left() + 10.0, rect.center().y), 3.0, tint); - ui.painter().galley( - egui::pos2(rect.left() + 18.0, rect.center().y - galley.size().y * 0.5), - galley, - theme::text(), - ); + ui.painter().galley(egui::pos2(rect.left() + 18.0, rect.center().y - galley.size().y * 0.5), galley, theme::text()); // Header buttons: Minimize (`—`), Separate/Pop-out (`🗗`), Close (`✕`) let btn_size = egui::vec2(20.0, 18.0); @@ -865,6 +851,18 @@ fn shorten_path(path: &str, home: &str) -> String { mod tests { use super::*; + #[test] + fn close_zone_freezes_both_compact_and_expanded_tabs() { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(160.0, 28.0)); + for x in [125.0, 140.0, 158.0] { + assert!(!usage_hover(rect, Some(egui::pos2(x, 14.0)), false)); + assert!(usage_hover(rect, Some(egui::pos2(x, 14.0)), true)); + } + assert!(usage_hover(rect, Some(egui::pos2(50.0, 14.0)), false)); + assert!(!usage_hover(rect, Some(egui::pos2(50.0, 40.0)), true)); + assert!(!usage_hover(rect, None, true)); + } + #[test] fn context_lines_add_the_branch() { assert_eq!(context_line("~/src/app", Some("main")), "~/src/app on main"); diff --git a/src/ui/term_grid.rs b/src/ui/term_grid.rs index 0b564e3..6e6c97d 100644 --- a/src/ui/term_grid.rs +++ b/src/ui/term_grid.rs @@ -341,10 +341,7 @@ mod tests { let (_, bgs, wide) = build_row(p.screen(), 0, 20, &Palette::TETHER_NIGHT, &FontId::monospace(12.0)); let red = Palette::TETHER_NIGHT.ansi[1]; let green = Palette::TETHER_NIGHT.ansi[2]; - assert_eq!( - bgs, - vec![BgSpan { col: 0, len: 3, color: red }, BgSpan { col: 3, len: 2, color: green }, BgSpan { col: 7, len: 3, color: red }] - ); + assert_eq!(bgs, vec![BgSpan { col: 0, len: 3, color: red }, BgSpan { col: 3, len: 2, color: green }, BgSpan { col: 7, len: 3, color: red }]); assert_eq!(wide, vec![7]); } diff --git a/src/ui/term_links.rs b/src/ui/term_links.rs index 1937a82..a2a34d4 100644 --- a/src/ui/term_links.rs +++ b/src/ui/term_links.rs @@ -31,17 +31,9 @@ pub enum LinkTarget { /// `http://` or `https://` URL. Url(String), /// A local file or folder (resolved absolute path, and whether it is a directory). - LocalPath { - resolved: PathBuf, - is_dir: bool, - line: Option, - col: Option, - }, + LocalPath { resolved: PathBuf, is_dir: bool, line: Option, col: Option }, /// A file path in a remote SSH session (resolved against remote cwd when relative). - RemotePath { - path: String, - line: Option, - }, + RemotePath { path: String, line: Option }, } /// A detected clickable link covering one or more row spans (when wrapped across lines). @@ -74,20 +66,17 @@ impl DetectedLink { pub enum LinkAction { /// Open this remote path in Tether's built-in editor. OpenRemote(String), + /// Browse the remote directory (or the containing folder for a file). + OpenSftp(String), } /// Cached existence lookup for `(cwd, raw_path)`. +#[derive(Default)] pub struct PathProbeCache { cwd: String, entries: HashMap, Instant)>, } -impl Default for PathProbeCache { - fn default() -> Self { - Self { cwd: String::new(), entries: HashMap::new() } - } -} - impl PathProbeCache { fn resolve(&mut self, cwd: Option<&str>, raw: &str) -> Option<(PathBuf, bool)> { let cwd_str = cwd.unwrap_or_default(); @@ -131,15 +120,7 @@ impl LinksState { } /// Updates the hovered link for `(hover_cell)` under the terminal lock. - pub fn sync( - &mut self, - screen: &vt100::Screen, - generation: u64, - scroll: usize, - hover_cell: Option<(u16, u16)>, - cwd: Option<&str>, - is_local: bool, - ) { + pub fn sync(&mut self, screen: &vt100::Screen, generation: u64, scroll: usize, hover_cell: Option<(u16, u16)>, cwd: Option<&str>, is_local: bool) { let Some((row, col)) = hover_cell else { self.hover_key = None; self.hovered = None; @@ -168,6 +149,10 @@ impl LinksState { /// Handles a click on the terminal; returns `true` if a link intercepted the click. pub fn on_click(&mut self, pos: Pos2, ctrl: bool, ctx: &egui::Context) -> (bool, Option) { if let Some(link) = self.hovered.clone() { + if !ctrl && let LinkTarget::RemotePath { path, .. } = &link.target { + self.active_menu = None; + return (true, Some(LinkAction::OpenSftp(path.clone()))); + } if ctrl { let action = execute_primary(&link.target, ctx); self.active_menu = None; @@ -182,6 +167,11 @@ impl LinksState { (false, None) } + pub fn on_secondary_click(&mut self, pos: Pos2) -> bool { + self.active_menu = self.hovered.clone().map(|link| (link, pos)); + self.active_menu.is_some() + } + /// Paints the underline for the hovered (or menu-active) link and renders the popup /// menu when open. pub fn paint_and_menu(&mut self, ui: &mut Ui, painter: &egui::Painter, inner: Rect, m: Metrics, accent: Color32) -> Option { @@ -198,13 +188,11 @@ impl LinksState { let kind = if *is_dir { "Folder" } else { "File" }; format!("{kind}: {}\nClick for options · Ctrl+click to open", resolved.display()) } - LinkTarget::RemotePath { path, .. } => format!("Remote: {path}\nClick for options · Ctrl+click to edit"), + LinkTarget::RemotePath { path, .. } => format!("Remote: {path}\nClick to open SFTP · Right-click for options · Ctrl+click to edit"), }; - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), Id::new("term_link_tip"), egui::PopupAnchor::Pointer) - .gap(10.0) - .show(|ui| { - ui.label(egui::RichText::new(tip).size(11.5).color(theme::text())); - }); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), Id::new("term_link_tip"), egui::PopupAnchor::Pointer).gap(10.0).show(|ui| { + ui.label(egui::RichText::new(tip).size(11.5).color(theme::text())); + }); } } self.show_menu(ui, inner, m) @@ -263,6 +251,10 @@ impl LinksState { LinkTarget::RemotePath { path, .. } => { path_header(ui, path); ui.separator(); + if menu_btn(ui, "📁 Open in SFTP") { + action = Some(LinkAction::OpenSftp(path.clone())); + close = true; + } if menu_btn(ui, "📝 Open in editor") { action = Some(LinkAction::OpenRemote(path.clone())); close = true; @@ -317,6 +309,7 @@ fn path_header(ui: &mut Ui, text: &str) { fn menu_btn(ui: &mut Ui, label: &str) -> bool { let (r, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 24.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.painter().rect_filled(r, CornerRadius::same(5), theme::accent().gamma_multiply(0.16)); @@ -451,10 +444,7 @@ impl LogicalLine { } fn byte_index_at(&self, row: u16, col: u16) -> Option { - self.cells - .iter() - .find(|c| c.row == row && col >= c.col && col < c.col + c.width) - .map(|c| c.byte_start) + self.cells.iter().find(|c| c.row == row && col >= c.col && col < c.col + c.width).map(|c| c.byte_start) } fn spans_for_bytes(&self, start_byte: usize, end_byte: usize) -> Vec { @@ -474,14 +464,7 @@ impl LogicalLine { } } -fn detect_at( - screen: &vt100::Screen, - row: u16, - col: u16, - cwd: Option<&str>, - is_local: bool, - cache: &mut PathProbeCache, -) -> Option { +fn detect_at(screen: &vt100::Screen, row: u16, col: u16, cwd: Option<&str>, is_local: bool, cache: &mut PathProbeCache) -> Option { let (line, _) = LogicalLine::extract(screen, row)?; let byte_idx = line.byte_index_at(row, col)?; let (raw, target, (start_b, end_b)) = find_link_in_str(&line.text, byte_idx, cwd, is_local, cache)?; @@ -514,9 +497,43 @@ pub fn find_link_in_str( let start = text[..byte_idx].rfind(char::is_whitespace).map_or(0, |i| i + text[i..].chars().next().map_or(1, char::len_utf8)); let end = text[byte_idx..].find(char::is_whitespace).map_or(text.len(), |i| byte_idx + i); let token = &text[start..end]; + if !is_local && let Some((path_start, path_end)) = prompt_path(token) { + let path_start = start + path_start; + let path_end = start + path_end; + if (path_start..path_end).contains(&byte_idx) { + let (raw, mut target, span) = classify_candidate(&text[path_start..path_end], path_start, cwd, false, cache)?; + // A shell can switch users with su/sudo while SFTP keeps the original + // authenticated account. Preserve whose home the prompt's ~ denotes. + if let LinkTarget::RemotePath { path, .. } = &mut target + && (path == "~" || path.starts_with("~/")) + { + let at = token.find('@')?; + let user = token[..at].trim_start_matches(['[', '(', '{']); + if crate::ssh::cwd::valid_home_user(user) { + path.insert_str(1, user); + } + } + return Some((raw, target, span)); + } + return None; + } classify_candidate(token, start, cwd, is_local, cache) } +/// The directory in a conventional SSH prompt: `[user@host:~/project]$`. +/// Only the path is linked; the host and prompt marker remain ordinary text. +fn prompt_path(token: &str) -> Option<(usize, usize)> { + let at = token.find('@')?; + let colon = token[at + 1..].find(":/").or_else(|| token[at + 1..].find(":~"))? + at + 1; + let host = &token[at + 1..colon]; + if host.is_empty() || !host.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_')) { + return None; + } + let start = colon + 1; + let end = token.trim_end_matches(['$', '#', '%', '❯', ']', ')', '}']).len(); + (end > start).then_some((start, end)) +} + fn enclosing_quotes(text: &str, byte_idx: usize) -> Option<(usize, usize)> { if !text.is_char_boundary(byte_idx) { return None; @@ -546,7 +563,7 @@ fn classify_candidate( cache: &mut PathProbeCache, ) -> Option<(String, LinkTarget, (usize, usize))> { let (trimmed, lead, _) = trim_wrappers(raw_token); - if trimmed.len() < 2 { + if trimmed.len() < 2 && (is_local || !matches!(trimmed, "/" | "~")) { return None; } let span_start = base_offset + lead; @@ -567,21 +584,17 @@ fn classify_candidate( // Strip optional `:line[:col]` or `(line[,col])` suffix. let (path_part, line, col) = split_line_col(trimmed); - if !looks_like_path_candidate(path_part) { + if !looks_like_path_candidate(path_part) && (is_local || !matches!(path_part, "/" | "~")) { return None; } if is_local { if let Some((resolved, is_dir)) = cache.resolve(cwd, path_part) { - return Some(( - path_part.to_string(), - LinkTarget::LocalPath { resolved, is_dir, line, col }, - (span_start, span_end), - )); + return Some((path_part.to_string(), LinkTarget::LocalPath { resolved, is_dir, line, col }, (span_start, span_end))); } } else if is_remote_path_syntax(path_part) { let resolved = match cwd { - Some(c) if !path_part.starts_with('/') && !path_part.starts_with("~/") && path_part != "~" => { + Some(c) if !path_part.starts_with('/') && !path_part.starts_with('~') => { crate::ui::path_complete::normalize_remote(&crate::ssh::sftp::join(c, path_part)) } _ => path_part.to_string(), @@ -646,7 +659,10 @@ pub fn split_line_col(s: &str) -> (&str, Option, Option) { break; } let tail = &rest[colon + 1..]; - if !tail.is_empty() && tail.bytes().all(|b| b.is_ascii_digit()) && let Ok(n) = tail.parse::() { + if !tail.is_empty() + && tail.bytes().all(|b| b.is_ascii_digit()) + && let Ok(n) = tail.parse::() + { nums.push(n); rest = &rest[..colon]; if nums.len() == 2 { @@ -678,16 +694,11 @@ fn looks_like_path_candidate(s: &str) -> bool { } fn is_remote_path_syntax(s: &str) -> bool { - if s.starts_with('/') || s.starts_with("~/") || s.starts_with("./") || s.starts_with("../") { + if s.starts_with('~') || s.starts_with('/') || s.starts_with("./") || s.starts_with("../") { return true; } - // Relative path with a slash and a file extension (`src/ui/terminal_view.rs`). - if let Some((_, file)) = s.rsplit_once('/') - && let Some((stem, ext)) = file.rsplit_once('.') - { - return !stem.is_empty() && !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric()); - } - false + // Remote folders do not need an extension or a trailing slash. + s.contains('/') && !s.contains(':') } /// Resolves `raw` against `cwd` (or home / root / MSYS / WSL drive mounts) and returns @@ -698,11 +709,7 @@ pub fn resolve_local_path(cwd: Option<&str>, raw: &str) -> Option<(PathBuf, bool return None; } // Strip git diff `a/` or `b/` prefix when the rest exists in `cwd`. - let candidates: &[&str] = if let Some(stripped) = raw.strip_prefix("a/").or_else(|| raw.strip_prefix("b/")) { - &[raw, stripped] - } else { - &[raw] - }; + let candidates: &[&str] = if let Some(stripped) = raw.strip_prefix("a/").or_else(|| raw.strip_prefix("b/")) { &[raw, stripped] } else { &[raw] }; for &item in candidates { if let Some(hit) = try_resolve_one(cwd, item) { return Some(hit); @@ -756,11 +763,7 @@ fn clean_path(path: &Path) -> PathBuf { Ok(canon) => { let s = canon.display().to_string(); // Strip Windows `\\?\` extended-length prefix for clean display & clipboard. - if let Some(stripped) = s.strip_prefix(r"\\?\") { - PathBuf::from(stripped) - } else { - canon - } + if let Some(stripped) = s.strip_prefix(r"\\?\") { PathBuf::from(stripped) } else { canon } } Err(_) => path.to_path_buf(), } @@ -770,6 +773,61 @@ fn clean_path(path: &Path) -> PathBuf { mod tests { use super::*; + #[test] + fn ssh_prompt_links_only_the_directory_and_opens_sftp_on_click() { + let mut cache = PathProbeCache::default(); + for (prompt, expected, resolved) in [ + ( + "prisma@na-prisma-02:~/servers/practice-servers/na-practice-01$", + "~/servers/practice-servers/na-practice-01", + "~prisma/servers/practice-servers/na-practice-01", + ), + ("root@server:/srv/app#", "/srv/app", "/srv/app"), + ("[dev@host:~/repo]$", "~/repo", "~dev/repo"), + ("dev@host:~$", "~", "~dev"), + ("root@host:/#", "/", "/"), + ] { + let start = prompt.find(':').unwrap() + 1; + let (raw, target, span) = find_link_in_str(prompt, start, Some("/old/cwd"), false, &mut cache).unwrap(); + assert_eq!(raw, expected); + assert_eq!(&prompt[span.0..span.1], expected); + assert_eq!(target, LinkTarget::RemotePath { path: resolved.into(), line: None }); + assert!(find_link_in_str(prompt, 2, None, false, &mut cache).is_none()); + let link = DetectedLink { raw, target, spans: Vec::new() }; + let mut links = LinksState { hovered: Some(link), ..Default::default() }; + assert_eq!(links.on_click(Pos2::ZERO, false, &egui::Context::default()), (true, Some(LinkAction::OpenSftp(resolved.into())))); + assert!(!links.has_open_menu()); + } + for folder in ["practice-01/", "servers/practice-01", "~/servers", "/", "~", "~prisma/servers"] { + assert!(find_link_in_str(folder, 0, None, false, &mut cache).is_some(), "{folder}"); + } + } + + #[test] + fn remote_path_menu_returns_an_sftp_action() { + use egui_kittest::kittest::Queryable; + let link = DetectedLink { + raw: "/srv/project".into(), + target: LinkTarget::RemotePath { path: "/srv/project".into(), line: None }, + spans: vec![RowSpan { row: 0, start_col: 0, end_col: 12 }], + }; + let mut links = LinksState { hovered: Some(link), ..Default::default() }; + assert!(links.on_secondary_click(egui::pos2(30.0, 12.0))); + let result = std::sync::Arc::new(std::sync::Mutex::new(None)); + let output = result.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui(move |ui| { + let painter = ui.painter().clone(); + let m = Metrics { cell_w: 8.0, cell_h: 18.0, text_dy: 0.0 }; + if let Some(action) = links.paint_and_menu(ui, &painter, ui.max_rect(), m, theme::accent()) { + *output.lock().unwrap() = Some(action); + } + }); + h.run_steps(5); + h.get_by_label("📁 Open in SFTP").click(); + h.run_steps(3); + assert_eq!(result.lock().unwrap().take(), Some(LinkAction::OpenSftp("/srv/project".into()))); + } + #[test] fn splits_line_and_column_suffixes() { assert_eq!(split_line_col("src/main.rs:42:10"), ("src/main.rs", Some(42), Some(10))); diff --git a/src/ui/terminal_bench.rs b/src/ui/terminal_bench.rs index 44bf8aa..15f61be 100644 --- a/src/ui/terminal_bench.rs +++ b/src/ui/terminal_bench.rs @@ -98,9 +98,7 @@ fn connected_terminal(rows: u16, cols: u16) -> TerminalView { fn grid_rects(sc: Scenario) -> Vec { let rows = TERMINALS.div_ceil(sc.per_row); let cell = Vec2::new(sc.screen.x / sc.per_row as f32, sc.screen.y / rows as f32); - (0..TERMINALS) - .map(|i| Rect::from_min_size(Pos2::new((i % sc.per_row) as f32 * cell.x, (i / sc.per_row) as f32 * cell.y), cell).shrink(3.0)) - .collect() + (0..TERMINALS).map(|i| Rect::from_min_size(Pos2::new((i % sc.per_row) as f32 * cell.x, (i / sc.per_row) as f32 * cell.y), cell).shrink(3.0)).collect() } fn frame(ctx: &egui::Context, views: &mut [TerminalView], rects: &[Rect], screen: Vec2, t: f64, profile: &Profile) -> Duration { @@ -223,9 +221,15 @@ fn render_snapshot() { if let Ok(mut s) = handle.shared.lock() { s.state = LinkState::Connected; for i in 0..40 { - s.parser.process(format!("\x1b[3{}mline {i}\x1b[0m \x1b[1;3{}mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munder\x1b[0m\r\n", i % 8, (i + 3) % 8).as_bytes()); + s.parser.process( + format!("\x1b[3{}mline {i}\x1b[0m \x1b[1;3{}mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munder\x1b[0m\r\n", i % 8, (i + 3) % 8) + .as_bytes(), + ); } - s.parser.process("\x1b[7m inverse \x1b[0m 漢字 wide \x1b[48;5;24m\x1b[38;5;220m 256 \x1b[0m \x1b[38;2;250;80;120m\x1b[48;2;20;60;40m truecolor \x1b[0m\r\n".as_bytes()); + s.parser.process( + "\x1b[7m inverse \x1b[0m 漢字 wide \x1b[48;5;24m\x1b[38;5;220m 256 \x1b[0m \x1b[38;2;250;80;120m\x1b[48;2;20;60;40m truecolor \x1b[0m\r\n" + .as_bytes(), + ); for i in 0..6 { s.parser.process(&claude_chunk(i, 96)); } diff --git a/src/ui/terminal_view.rs b/src/ui/terminal_view.rs index b01ca3e..5889186 100644 --- a/src/ui/terminal_view.rs +++ b/src/ui/terminal_view.rs @@ -50,6 +50,8 @@ pub fn metrics(ctx: &egui::Context, font: &FontId, line_height: f32) -> Metrics } pub struct TerminalView { + /// Enabled by the tab's foreground-agent detector, independent of badge visibility. + pub images_enabled: bool, pub handle: TerminalHandle, size: (u16, u16), scroll: usize, @@ -66,14 +68,10 @@ pub struct TerminalView { last_stage: Stage, /// Cursor `(row, col)` when the user started typing on the current shell prompt. prompt_col: Option<(u16, u16)>, - /// Characters typed since the last submitted/cancelled line (fallback before echo). - typed_line: String, /// Hand `nano ` to the built-in SFTP editor (off for local shells). pub intercept_nano: bool, /// Take keyboard focus on the next frame (the agents panel jumping to this tab). pub want_focus: bool, - /// The current Ctrl+V press was already handled (text paste or the fallback below). - ctrl_v_handled: bool, /// Fallback working directory (e.g. from an AI agent hook when OSC 7 hasn't fired). pub fallback_cwd: Option, /// Boxed to keep `TabBody` small. @@ -82,6 +80,12 @@ pub struct TerminalView { /// Painted rows and the last header info seen. struct ViewCache { + /// Clipboard and file-drop input must only be handled once per gesture/frame. + paste_guard: super::os_input::PasteGuard, + last_drop_frame: Option, + /// Characters typed since the last submitted/cancelled line (fallback before echo). + typed_line: String, + attachments: super::attachments::Attachments, grid: GridCache, /// Last OSC title and link state seen, for the tab header (read without blocking). title: Option, @@ -114,11 +118,27 @@ pub struct ViewOutput { pub reconnect: bool, /// Remote files the user asked to edit from the shell (e.g. `nano `). pub open_files: Vec, + pub open_folders: Vec, +} + +impl ViewOutput { + fn link_action(&mut self, action: Option) { + match action { + Some(super::term_links::LinkAction::OpenRemote(path)) => self.open_files.push(path), + Some(super::term_links::LinkAction::OpenSftp(path)) => self.open_folders.push(path), + None => {} + } + } } impl TerminalView { + pub fn attachments(&mut self) -> &mut super::attachments::Attachments { + &mut self.cache.attachments + } + pub fn new(handle: TerminalHandle) -> Self { Self { + images_enabled: false, handle, size: (0, 0), scroll: 0, @@ -132,13 +152,15 @@ impl TerminalView { connecting_since: None, last_stage: Stage::Connecting, prompt_col: None, - typed_line: String::new(), intercept_nano: true, want_focus: false, - ctrl_v_handled: false, fallback_cwd: None, cache: Box::new(ViewCache { + paste_guard: Default::default(), + last_drop_frame: None, grid: GridCache::default(), + typed_line: String::new(), + attachments: Default::default(), title: None, cwd: None, link: LinkState::Connecting, @@ -185,7 +207,10 @@ impl TerminalView { let mut out = ViewOutput::default(); let settings = settings::snapshot(); let cfg = &settings.terminal; - let rect = ui.available_rect_before_wrap(); + let full_rect = ui.available_rect_before_wrap(); + let bar_height = if self.images_enabled { 34.0 } else { 0.0 }; + let bar_rect = Rect::from_min_max(egui::pos2(full_rect.left(), (full_rect.bottom() - bar_height).max(full_rect.top())), full_rect.max); + let rect = Rect::from_min_max(full_rect.min, egui::pos2(full_rect.right(), bar_rect.top())); let id = ui.id().with("terminal"); let response = ui.interact(rect, id, Sense::click_and_drag()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Terminal")); @@ -226,6 +251,12 @@ impl TerminalView { // Glyph layout for new rows happens with the lock released. self.cache.grid.layout(ui.ctx()); let FrameInfo { state, stage, bells, history, app_cursor, bracketed, alt_screen, cursor } = frame; + let ready = self.cache.attachments.poll(ui.ctx()); + if self.images_enabled && !ready.is_empty() { + // Each image belongs to this terminal/SSH target, even during broadcast. + self.handle.send(keys::encode_paste(&super::os_input::format_paths(&ready), bracketed)); + self.want_focus = true; + } self.cache.link.clone_from(&state); let to_cell = |p: Pos2| Cell { @@ -238,9 +269,7 @@ impl TerminalView { let sel = self.selection.map(|(a, b)| if a <= b { (a, b) } else { (b, a) }); self.cache.grid.paint(&painter, inner.min, m, |row| selection_cols(sel, row, cols)); if !alt_screen { - if let Some(super::term_links::LinkAction::OpenRemote(path)) = self.cache.links.paint_and_menu(ui, &painter, inner, m, accent) { - out.open_files.push(path); - } + out.link_action(self.cache.links.paint_and_menu(ui, &painter, inner, m, accent)); let geom = super::blocks::Geom { rect, inner, cell_h: m.cell_h, rows }; if let Some(req) = self.cache.blocks.paint(ui, geom, &settings.shell_integration, accent) { self.block_request(req, ui.ctx(), &mut out); @@ -290,6 +319,9 @@ impl TerminalView { } self.connecting_screen(ui, rect, &state, stage, profile, now); out.reconnect = state_overlay(ui, rect, &state); + if self.images_enabled { + self.cache.attachments.bar(ui, bar_rect); + } if !out.input.is_empty() { self.scroll = 0; self.selection = None; @@ -300,14 +332,7 @@ impl TerminalView { /// Locks the terminal just long enough to clamp the scroll offset, refresh the row /// cache and copy out what the rest of the frame needs. `None` if the lock is poisoned. - fn sync_grid( - &mut self, - size: (u16, u16), - font: &FontId, - palette: &theme::Palette, - render: RenderKey, - hover_cell: Option<(u16, u16)>, - ) -> Option { + fn sync_grid(&mut self, size: (u16, u16), font: &FontId, palette: &theme::Palette, render: RenderKey, hover_cell: Option<(u16, u16)>) -> Option { let shared_arc = self.handle.shared.clone(); let mut shared = shared_arc.lock().ok()?; let generation = shared.generation; @@ -526,6 +551,13 @@ impl TerminalView { (alt_screen, app_cursor): (bool, bool), out: &mut ViewOutput, ) { + if !alt_screen + && response.secondary_clicked() + && let Some(pos) = response.interact_pointer_pos() + && self.cache.links.on_secondary_click(pos) + { + return; + } if response.drag_started() { if let Some(p) = response.interact_pointer_pos() { self.selection = Some((to_cell(p), to_cell(p))); @@ -536,15 +568,11 @@ impl TerminalView { } } else if response.clicked() { let ctrl = ui.input(|i| i.modifiers.ctrl || i.modifiers.command); - if !alt_screen - && let Some(p) = response.interact_pointer_pos() - { + if !alt_screen && let Some(p) = response.interact_pointer_pos() { let (intercepted, action) = self.cache.links.on_click(p, ctrl, ui.ctx()); if intercepted { self.selection = None; - if let Some(super::term_links::LinkAction::OpenRemote(path)) = action { - out.open_files.push(path); - } + out.link_action(action); return; } } @@ -593,17 +621,32 @@ impl TerminalView { Event::Text(t) => { if !mods.ctrl && !mods.alt { self.mark_prompt_start(); - self.typed_line.push_str(&t); + self.cache.typed_line.push_str(&t); } out.input.extend(keys::encode_text(&t, mods.alt && !mods.ctrl)); } Event::Paste(t) => { + if !self.cache.paste_guard.explicit(ui.ctx().cumulative_frame_nr()) { + continue; + } + let files = super::os_input::clipboard_files(); + if self.images_enabled && files.iter().any(|p| super::attachments::is_image(p)) { + let (images, other): (Vec<_>, Vec<_>) = files.into_iter().partition(|p| super::attachments::is_image(p)); + self.cache.attachments.add(images, ui.ctx()); + if !other.is_empty() { + out.input.extend(keys::encode_paste(&super::os_input::format_paths(&other), bracketed)); + } + continue; + } + if self.images_enabled && super::os_input::clipboard_has_image() && self.cache.attachments.paste_image(ui.ctx()) { + continue; + } self.mark_prompt_start(); if let Some(last) = t.rsplit(['\n', '\r']).next() { if t.contains(['\n', '\r']) { - self.typed_line.clear(); + self.cache.typed_line.clear(); } - self.typed_line.push_str(last); + self.cache.typed_line.push_str(last); } out.input.extend(keys::encode_paste(&t, bracketed)); } @@ -614,12 +657,16 @@ impl TerminalView { } _ => { self.prompt_col = None; - self.typed_line.clear(); + self.cache.typed_line.clear(); out.input.push(0x03); } }, Event::Cut => out.input.push(0x18), Event::Key { key, pressed: true, modifiers, .. } => { + if self.images_enabled && key == egui::Key::Enter && !modifiers.shift && self.cache.attachments.is_pending() { + self.cache.attachments.notice = Some("Preparing images… Press Enter once the attachment is ready.".into()); + continue; + } if let Some(up) = super::blocks::step_key(key, modifiers).filter(|_| self.cache.blocks.active()) { self.block_request(super::blocks::Request::Step(up), ui.ctx(), out); continue; @@ -631,11 +678,13 @@ impl TerminalView { } if matches!(key, egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::Tab) { self.mark_prompt_start(); + } else if key == egui::Key::Enter && modifiers.shift && !modifiers.ctrl && !modifiers.alt { + self.cache.typed_line.push('\n'); } else if key == egui::Key::Backspace && !modifiers.ctrl && !modifiers.alt { - self.typed_line.pop(); + self.cache.typed_line.pop(); } else if (modifiers.ctrl || modifiers.command) && matches!(key, egui::Key::C | egui::Key::U) { self.prompt_col = None; - self.typed_line.clear(); + self.cache.typed_line.clear(); } else if key == egui::Key::Enter && !modifiers.shift && !modifiers.ctrl && !modifiers.alt && !modifiers.command { if let Some(files) = self.take_nano_command().filter(|_| self.intercept_nano) { // Cancel the typed command line in the remote shell and open the file(s) in Tether's editor. @@ -663,8 +712,7 @@ impl TerminalView { /// Ctrl+V with no text on the clipboard: egui-winit drops the key press entirely, so /// it is detected from the physical key state / the V release instead. Files copied in - /// Explorer paste as their paths; an image (screenshot) sends Alt+V (`ESC v`), which is - /// Claude Code's "paste image" key on Windows. + /// Explorer paste as paths; screenshots become image attachments. fn paste_without_text(&mut self, ui: &Ui, bracketed: bool, out: &mut ViewOutput) { let (had_paste, released_v) = ui.input(|i| { let paste = i.events.iter().any(|e| matches!(e, Event::Paste(_))); @@ -674,27 +722,26 @@ impl TerminalView { (paste, release) }); let down = super::os_input::ctrl_v_down(); - if had_paste { - self.ctrl_v_handled = true; - } - if !self.ctrl_v_handled && (down || released_v) { - self.ctrl_v_handled = true; - self.paste_clipboard_non_text(bracketed, out); - } - if !down && !had_paste { - self.ctrl_v_handled = false; + if self.cache.paste_guard.fallback(ui.ctx().cumulative_frame_nr(), down, released_v, had_paste) { + self.paste_clipboard_non_text(ui.ctx(), bracketed, out); } } - fn paste_clipboard_non_text(&mut self, bracketed: bool, out: &mut ViewOutput) { + fn paste_clipboard_non_text(&mut self, ctx: &egui::Context, bracketed: bool, out: &mut ViewOutput) { let files = super::os_input::clipboard_files(); if !files.is_empty() { - self.mark_prompt_start(); - out.input.extend(keys::encode_paste(&super::os_input::format_paths(&files), bracketed)); + let (images, files): (Vec<_>, Vec<_>) = files.into_iter().partition(|p| self.images_enabled && super::attachments::is_image(p)); + if !images.is_empty() { + self.cache.attachments.add(images, ctx); + } + if !files.is_empty() { + self.mark_prompt_start(); + out.input.extend(keys::encode_paste(&super::os_input::format_paths(&files), bracketed)); + } return; } - if super::os_input::clipboard_has_image() && !super::os_input::clipboard_has_text() { - out.input.extend_from_slice(b"\x1bv"); + if self.images_enabled && super::os_input::clipboard_has_image() && !super::os_input::clipboard_has_text() { + self.cache.attachments.paste_image(ctx); } } @@ -713,15 +760,27 @@ impl TerminalView { if glow > 0.0 { let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(theme::RADIUS), theme::accent().gamma_multiply(0.08 * glow)); - painter.rect_stroke(rect.shrink(1.0), CornerRadius::same(theme::RADIUS), Stroke::new(1.5, theme::accent().gamma_multiply(glow)), egui::StrokeKind::Inside); + painter.rect_stroke( + rect.shrink(1.0), + CornerRadius::same(theme::RADIUS), + Stroke::new(1.5, theme::accent().gamma_multiply(glow)), + egui::StrokeKind::Inside, + ); painter.text(rect.center(), Align2::CENTER_CENTER, "Drop to insert path", FontId::proportional(14.0), theme::text().gamma_multiply(glow)); ui.ctx().request_repaint(); } if dropped.is_empty() || !over { return false; } + let frame = ui.ctx().cumulative_frame_nr(); + if self.cache.last_drop_frame == Some(frame) { + return false; + } + self.cache.last_drop_frame = Some(frame); self.mark_prompt_start(); - out.input.extend(keys::encode_paste(&super::os_input::format_paths(&dropped), bracketed)); + let (images, files): (Vec<_>, Vec<_>) = dropped.into_iter().partition(|p| self.images_enabled && super::attachments::is_image(p)); + self.cache.attachments.add(images, ui.ctx()); + out.input.extend(keys::encode_paste(&super::os_input::format_paths(&files), bracketed)); true } @@ -770,7 +829,10 @@ impl TerminalView { /// `nano ` invocation that should open in Tether's built-in editor. fn take_nano_command(&mut self) -> Option> { let prompt = self.prompt_col.take(); - let typed = std::mem::take(&mut self.typed_line); + let typed = std::mem::take(&mut self.cache.typed_line); + if typed.contains(['\r', '\n']) { + return None; + } let shared = self.handle.shared.lock().ok()?; let screen = shared.parser.screen(); if screen.alternate_screen() { @@ -882,7 +944,19 @@ pub fn parse_nano_command(cmd: &str) -> Option> { if arg.starts_with('-') && arg.len() > 1 { if matches!( arg.as_str(), - "-C" | "--backupdir" | "-o" | "--operatingdir" | "-r" | "--fill" | "-s" | "--speller" | "-T" | "--tabsize" | "-Q" | "--quotestr" | "-Y" | "--syntax" + "-C" | "--backupdir" + | "-o" + | "--operatingdir" + | "-r" + | "--fill" + | "-s" + | "--speller" + | "-T" + | "--tabsize" + | "-Q" + | "--quotestr" + | "-Y" + | "--syntax" ) { let _ = args.next(); } @@ -927,11 +1001,11 @@ fn split_shell_words(cmd: &str) -> Option> { } if dc == '\\' && let Some(&next) = chars.peek() - && matches!(next, '"' | '\\' | '$' | '`') - { - cur.push(chars.next().unwrap()); - continue; - } + && matches!(next, '"' | '\\' | '$' | '`') + { + cur.push(chars.next().unwrap()); + continue; + } if matches!(dc, '$' | '`') { return None; } @@ -1059,6 +1133,139 @@ mod bench; mod tests { use super::*; + #[test] + fn image_button_follows_foreground_agent_and_releases_its_space() { + use egui_kittest::kittest::Queryable; + let enabled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let state = enabled.clone(); + let (handle, _rx) = TerminalHandle::new(24, 80); + handle.shared.lock().unwrap().state = LinkState::Connected; + let mut view = TerminalView::new(handle); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(800.0, 500.0)).build_ui(move |ui| { + view.images_enabled = state.load(std::sync::atomic::Ordering::Relaxed); + view.ui(ui, &Profile::new()); + }); + h.run_steps(3); + assert!(h.query_by_label("+ Image").is_none()); + let height = h.get_by_label("Terminal").rect().height(); + enabled.store(true, std::sync::atomic::Ordering::Relaxed); + h.run_steps(3); + h.get_by_label("+ Image"); + assert_eq!(h.get_by_label("Terminal").rect().height(), height - 34.0); + enabled.store(false, std::sync::atomic::Ordering::Relaxed); + h.run_steps(3); + assert!(h.query_by_label("+ Image").is_none()); + assert_eq!(h.get_by_label("Terminal").rect().height(), height); + } + + #[test] + fn clicking_the_ssh_prompt_directory_emits_sftp_not_terminal_input() { + use egui_kittest::kittest::Queryable; + let prompt = "prisma@na-prisma-02:~/servers/practice-servers/na-practice-01$"; + let (handle, _rx) = TerminalHandle::new(24, 100); + { + let mut s = handle.shared.lock().unwrap(); + s.state = LinkState::Connected; + s.parser.process(prompt.as_bytes()); + } + let mut view = TerminalView::new(handle); + let actions = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let output = actions.clone(); + let pointer = std::sync::Arc::new(std::sync::Mutex::new(Pos2::ZERO)); + let position = pointer.clone(); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(1000.0, 500.0)).build_ui(move |ui| { + let settings = settings::snapshot(); + let cfg = &settings.terminal; + let font = FontId::new(cfg.font_size(), FontFamily::Monospace); + let m = metrics(ui.ctx(), &font, cfg.line_height()); + let inner = ui.available_rect_before_wrap().shrink(cfg.padding()); + *position.lock().unwrap() = inner.min + Vec2::new((prompt.find("servers").unwrap() as f32 + 2.5) * m.cell_w, m.cell_h * 0.5); + let out = view.ui(ui, &Profile::new()); + assert!(out.input.is_empty()); + output.lock().unwrap().extend(out.open_folders); + }); + h.run_steps(3); + let pos = *pointer.lock().unwrap(); + h.event(Event::PointerMoved(pos)); + h.run_steps(2); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + assert_eq!(&*actions.lock().unwrap(), &["~prisma/servers/practice-servers/na-practice-01"]); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + h.get_by_label("📁 Open in SFTP").click(); + h.run_steps(3); + assert_eq!(actions.lock().unwrap().len(), 2); + } + + #[test] + fn first_image_drop_is_sent_once_even_when_layout_repeats() { + use crate::ssh::terminal::TermCmd; + #[derive(Debug)] + struct FileDrop(std::path::PathBuf); + impl egui::DroppedFile for FileDrop { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.png"); + image::RgbaImage::new(8, 8).save(&path).unwrap(); + let (handle, mut rx) = TerminalHandle::new(24, 80); + { + let mut s = handle.shared.lock().unwrap(); + s.state = LinkState::Connected; + s.parser.process(b"\x1b[?2004h"); + } + let mut view = TerminalView::new(handle); + view.images_enabled = true; + let ctx = egui::Context::default(); + let mut sent = Vec::new(); + for gesture in 0..2 { + let mut repeat_layout = true; + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 400.0))), + events: vec![Event::PointerMoved(Pos2::new(100.0, 100.0))], + dropped_files: vec![std::sync::Arc::new(FileDrop(path.clone()))], + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| { + let out = view.ui(ui, &Profile::new()); + assert!(out.input.is_empty(), "image-only drops must not send an empty bracketed paste"); + if std::mem::take(&mut repeat_layout) { + ctx.request_discard("exercise repeated input during attachment layout"); + } + }); + output.textures_delta.clear(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while view.cache.attachments.is_pending() { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 400.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + view.ui(ui, &Profile::new()); + }); + output.textures_delta.clear(); + assert!(std::time::Instant::now() < deadline, "attachment worker did not finish"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + while let Ok(cmd) = rx.try_recv() { + if let TermCmd::Input(bytes) = cmd { + sent.push(bytes); + } + } + assert_eq!(sent.len(), gesture + 1, "each intentional drop must send exactly one path"); + let text = String::from_utf8_lossy(sent.last().unwrap()); + assert_eq!(text.matches(".png").count(), 1); + assert!(text.starts_with("\x1b[200~") && text.ends_with("\x1b[201~")); + } + } + #[test] fn selection_spans_rows() { let sel = Some((Cell { row: 1, col: 5 }, Cell { row: 3, col: 2 })); diff --git a/src/ui/vertical_tabs.rs b/src/ui/vertical_tabs.rs index 4ef864f..0c6d8d5 100644 --- a/src/ui/vertical_tabs.rs +++ b/src/ui/vertical_tabs.rs @@ -29,10 +29,16 @@ const EDGE: f32 = 0.28; #[derive(Debug, Clone, Copy, PartialEq)] pub enum TabTarget { /// Into `leaf` at `index` (counted before the dragged tab is removed). - Insert { leaf: NodePath, index: usize }, + Insert { + leaf: NodePath, + index: usize, + }, Append(NodePath), /// A new pane next to `leaf`. - Split { leaf: NodePath, split: Split }, + Split { + leaf: NodePath, + split: Split, + }, } /// What the tab list (or a dock tab's context menu) asks the app to do. @@ -51,7 +57,10 @@ pub enum TabAction { PopOut(u64), /// Minimize a popped-out / floating window (or tab) into the mini-dock bar. MinimizeWindow(u64), - Move { uid: u64, target: TabTarget }, + Move { + uid: u64, + target: TabTarget, + }, /// A local shell by id; `None` = the default shell. NewShell(Option), SetGrouped(bool), @@ -179,8 +188,11 @@ impl VerticalTabs { let by_pane = opts.grouped && groups.len() > 1; let rows: Vec<&RowModel> = groups.iter().flat_map(|g| g.rows.iter()).collect(); let per_col = ((ui.available_height() - 8.0) / row_h(opts)).floor().max(1.0) as usize; - let cols: Vec<(Option, Vec<&RowModel>)> = - if by_pane { groups.iter().enumerate().map(|(i, g)| (Some(i), g.rows.iter().collect())).collect() } else { rows.chunks(per_col).map(|c| (None, c.to_vec())).collect() }; + let cols: Vec<(Option, Vec<&RowModel>)> = if by_pane { + groups.iter().enumerate().map(|(i, g)| (Some(i), g.rows.iter().collect())).collect() + } else { + rows.chunks(per_col).map(|c| (None, c.to_vec())).collect() + }; let col_w = ((ui.available_width() - 8.0 * cols.len() as f32) / cols.len() as f32).clamp(COLUMN_MIN, COLUMN_MAX); egui::ScrollArea::both().id_salt("vtabs_columns").auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { @@ -275,7 +287,10 @@ impl VerticalTabs { let mut badges_w = 0.0; if !renaming && let Some(tab) = tab { let line = if opts.compact { card } else { Rect::from_min_max(card.min, Pos2::new(card.right(), card.center().y + 1.0)) }; - let badge_rect = Rect::from_min_max(Pos2::new(text_x + 40.0, line.top() + if opts.compact { 0.0 } else { 6.0 }), Pos2::new(card.right() - 8.0 - close_w * hover, line.bottom())); + let badge_rect = Rect::from_min_max( + Pos2::new(text_x + 40.0, line.top() + if opts.compact { 0.0 } else { 6.0 }), + Pos2::new(card.right() - 8.0 - close_w * hover, line.bottom()), + ); // A child that doesn't allocate in the list (the row already did). let mut badges = ui.new_child(UiBuilder::new().max_rect(badge_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); if self.badges.row_badges(tab, &mut badges, opts.compact) { @@ -288,7 +303,12 @@ impl VerticalTabs { } let right = card.right() - 6.0 - (close_w * hover).max(badges_w); if renaming { - self.rename_field(ui, row.uid, Rect::from_min_max(Pos2::new(text_x - 4.0, card.top() + 5.0), Pos2::new(card.right() - 6.0, card.bottom() - 5.0)), actions); + self.rename_field( + ui, + row.uid, + Rect::from_min_max(Pos2::new(text_x - 4.0, card.top() + 5.0), Pos2::new(card.right() - 6.0, card.bottom() - 5.0)), + actions, + ); } else { let tp = p.with_clip_rect(card.intersect(ui.clip_rect())); let title_col = if row.visible { theme::text() } else { theme::text_dim().lerp_to_gamma(theme::text(), hover) }; @@ -613,7 +633,13 @@ fn group_header(ui: &mut Ui, index: usize, group: &Group) -> egui::Response { }; p.text(Pos2::new(rect.left() + 22.0, y), Align2::LEFT_CENTER, label, FontId::proportional(10.5), col); let n = group.rows.len(); - p.text(Pos2::new(rect.right() - 6.0, y), Align2::RIGHT_CENTER, format!("{n} tab{}", if n == 1 { "" } else { "s" }), FontId::proportional(10.5), theme::text_faint().gamma_multiply(0.8)); + p.text( + Pos2::new(rect.right() - 6.0, y), + Align2::RIGHT_CENTER, + format!("{n} tab{}", if n == 1 { "" } else { "s" }), + FontId::proportional(10.5), + theme::text_faint().gamma_multiply(0.8), + ); resp } @@ -658,7 +684,8 @@ pub fn paint_ghost(ctx: &egui::Context, pos: Pos2, icon: &str, title: &str, hue: pub fn drop_on_panes(ctx: &egui::Context, dock: &DockState, area: Rect) -> Option { let drag = *egui::DragAndDrop::payload::(ctx)?; let pos = ctx.pointer_latest_pos().filter(|p| area.contains(*p))?; - let (leaf, rect) = dock.iter_leaves().filter(|(p, l)| p.surface.is_main() && !l.tabs.is_empty()).map(|(p, l)| (p, l.rect)).find(|(_, r)| r.contains(pos))?; + let (leaf, rect) = + dock.iter_leaves().filter(|(p, l)| p.surface.is_main() && !l.tabs.is_empty()).map(|(p, l)| (p, l.rect)).find(|(_, r)| r.contains(pos))?; let (target, preview) = pane_zone(leaf, rect, pos); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("vtabs_pane_drop"))); let r = preview.shrink(4.0); diff --git a/src/ui/welcome.rs b/src/ui/welcome.rs index cd013b2..ddcacb2 100644 --- a/src/ui/welcome.rs +++ b/src/ui/welcome.rs @@ -75,7 +75,10 @@ fn shells(ui: &mut Ui, out: &mut Option) { let Some(default) = crate::pty::shells::pick_default(shells, &crate::settings::get().terminal.default_shell) else { return }; card(ui, "Start", |ui| { let text = format!("{} New {}", default.icon_kind.glyph(), default.name); - if wide_button(ui, &text, keybindings::label(Action::NewLocalTerminal), true).on_hover_text(keybindings::hint("Local terminal", Action::NewLocalTerminal)).clicked() { + if wide_button(ui, &text, keybindings::label(Action::NewLocalTerminal), true) + .on_hover_text(keybindings::hint("Local terminal", Action::NewLocalTerminal)) + .clicked() + { *out = Some(WelcomeAction::Shell(default)); } let others: Vec<&'static ShellSpec> = shells.iter().filter(|s| s.id != default.id).collect(); @@ -84,7 +87,9 @@ fn shells(ui: &mut Ui, out: &mut Option) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = Vec2::new(6.0, 6.0); for s in others { - let chip = egui::Button::new(RichText::new(format!("{} {}", s.icon_kind.glyph(), s.name)).size(12.0)).fill(theme::surface_2()).corner_radius(CornerRadius::same(7)); + let chip = egui::Button::new(RichText::new(format!("{} {}", s.icon_kind.glyph(), s.name)).size(12.0)) + .fill(theme::surface_2()) + .corner_radius(CornerRadius::same(7)); if ui.add(chip).clicked() { *out = Some(WelcomeAction::Shell(s)); } @@ -106,10 +111,16 @@ fn hosts(ui: &mut Ui, profiles: &[Profile], out: &mut Option) { } ui.add_space(6.0); ui.columns(2, |cols| { - if cols[0].add(egui::Button::new(RichText::new("+ New host").size(12.5)).min_size(Vec2::new(cols[0].available_width(), 32.0)).fill(theme::accent_soft())).clicked() { + if cols[0] + .add(egui::Button::new(RichText::new("+ New host").size(12.5)).min_size(Vec2::new(cols[0].available_width(), 32.0)).fill(theme::accent_soft())) + .clicked() + { *out = Some(WelcomeAction::NewHost); } - if cols[1].add(egui::Button::new(RichText::new("⬇ Import from Bitvise / SSH config").size(12.5)).min_size(Vec2::new(cols[1].available_width(), 32.0))).clicked() { + if cols[1] + .add(egui::Button::new(RichText::new("⬇ Import from Bitvise / SSH config").size(12.5)).min_size(Vec2::new(cols[1].available_width(), 32.0))) + .clicked() + { *out = Some(WelcomeAction::Import); } }); @@ -132,7 +143,13 @@ fn host_row(ui: &mut Ui, p: &Profile) -> egui::Response { let initial = p.display_name().chars().next().unwrap_or('?').to_uppercase().to_string(); paint.text(tile.center(), Align2::CENTER_CENTER, initial, FontId::proportional(12.0), tint); paint.text(Pos2::new(tile.right() + 10.0, rect.center().y), Align2::LEFT_CENTER, p.display_name(), FontId::proportional(13.0), theme::text()); - paint.text(Pos2::new(rect.right() - 12.0, rect.center().y), Align2::RIGHT_CENTER, p.address(), FontId::proportional(11.5), theme::text_faint().lerp_to_gamma(theme::text_dim(), h)); + paint.text( + Pos2::new(rect.right() - 12.0, rect.center().y), + Align2::RIGHT_CENTER, + p.address(), + FontId::proportional(11.5), + theme::text_faint().lerp_to_gamma(theme::text_dim(), h), + ); resp } @@ -184,7 +201,8 @@ fn cheat_sheet(ui: &mut Ui, out: &mut Option) { } }); ui.add_space(6.0); - let link = ui.add(egui::Label::new(RichText::new("Everything else is in the command palette ›").size(11.5).color(theme::accent())).sense(Sense::click())); + let link = + ui.add(egui::Label::new(RichText::new("Everything else is in the command palette ›").size(11.5).color(theme::accent())).sense(Sense::click())); if link.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } diff --git a/src/views.rs b/src/views.rs index 960515c..2de912e 100644 --- a/src/views.rs +++ b/src/views.rs @@ -14,11 +14,30 @@ pub enum View { Workspaces, /// AI agents running in terminals. Agents, + /// Dedicated Codex terminal sessions. + Codex, } impl View { /// Every view, in the default order of the primary side bar. - pub const ALL: [View; 4] = [View::Sessions, View::Hosts, View::Workspaces, View::Agents]; + pub const ALL: [View; 5] = [View::Sessions, View::Hosts, View::Workspaces, View::Agents, View::Codex]; + + /// The Claude section keeps support for other detected CLIs; Codex has its own view. + pub fn includes_agent(self, kind: crate::agents::AgentKind) -> bool { + match self { + View::Agents => kind != crate::agents::AgentKind::Codex, + View::Codex => kind == crate::agents::AgentKind::Codex, + _ => false, + } + } + + pub fn agent(self) -> Option { + match self { + View::Agents => Some(crate::agents::AgentKind::Claude), + View::Codex => Some(crate::agents::AgentKind::Codex), + _ => None, + } + } pub fn title(self) -> &'static str { match self { @@ -26,6 +45,7 @@ impl View { View::Sessions => "Sessions", View::Workspaces => "Workspaces", View::Agents => "Agents", + View::Codex => "Codex", } } } @@ -314,15 +334,15 @@ mod tests { let mut l = ViewLayout::default(); // Sessions, Hosts, Workspaces, Agents → drop Sessions before Agents (index 3). l.move_view(View::Sessions, Container::Primary, Some(3)); - assert_eq!(order(&l, Container::Primary), vec![View::Hosts, View::Workspaces, View::Sessions, View::Agents]); + assert_eq!(order(&l, Container::Primary), vec![View::Hosts, View::Workspaces, View::Sessions, View::Agents, View::Codex]); l.move_view(View::Agents, Container::Primary, Some(0)); - assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Hosts, View::Workspaces, View::Sessions]); + assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Hosts, View::Workspaces, View::Sessions, View::Codex]); l.move_view(View::Hosts, Container::Primary, None); - assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Workspaces, View::Sessions, View::Hosts]); + assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Workspaces, View::Sessions, View::Codex, View::Hosts]); // Onto itself: no change. l.move_view(View::Workspaces, Container::Primary, Some(1)); l.move_view(View::Workspaces, Container::Primary, Some(2)); - assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Workspaces, View::Sessions, View::Hosts]); + assert_eq!(order(&l, Container::Primary), vec![View::Agents, View::Workspaces, View::Sessions, View::Codex, View::Hosts]); } #[test] @@ -359,7 +379,7 @@ mod tests { ) .unwrap(); l.normalize(); - assert_eq!(order(&l, Container::Primary), vec![View::Hosts, View::Sessions]); + assert_eq!(order(&l, Container::Primary), vec![View::Hosts, View::Sessions, View::Codex]); assert_eq!(order(&l, Container::Panel), vec![View::Agents]); assert_eq!(l.hidden, vec![View::Workspaces]); assert_eq!(l.primary.active, Some(View::Hosts)); @@ -376,6 +396,27 @@ mod tests { assert_eq!(l.size(Container::Panel), PANEL_DEFAULT); } + #[test] + fn codex_is_added_to_existing_layouts_and_moves_independently() { + let mut l: ViewLayout = serde_json::from_str( + r#"{"primary":{"views":["Hosts","Sessions"],"active":"Hosts"},"panel":{"views":["Agents"],"active":"Agents"},"hidden":["Workspaces"]}"#, + ) + .unwrap(); + l.normalize(); + assert_eq!(l.container_of(View::Codex), Some(Container::Primary)); + assert_eq!(l.primary.active, Some(View::Hosts)); + l.move_view(View::Codex, Container::Secondary, None); + assert!(l.is_shown(View::Codex) && l.is_shown(View::Agents)); + l.hide(View::Codex); + assert!(l.is_shown(View::Agents)); + l.reveal(View::Codex); + assert!(l.is_shown(View::Codex)); + assert!(View::Codex.includes_agent(crate::agents::AgentKind::Codex)); + assert!(!View::Codex.includes_agent(crate::agents::AgentKind::Claude)); + assert!(!View::Agents.includes_agent(crate::agents::AgentKind::Codex)); + assert!(View::Agents.includes_agent(crate::agents::AgentKind::Claude)); + } + #[test] fn serde_round_trips() { let mut l = ViewLayout::default(); diff --git a/src/workspace.rs b/src/workspace.rs index a4fb560..d1e1d5c 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -24,7 +24,9 @@ pub enum TabKind { Terminal, Sftp, /// A local shell (`pty::shells`); dropped on restore if that shell is gone. - Local { shell_id: String }, + Local { + shell_id: String, + }, /// A kind this version doesn't know (saved by a newer tether): skipped on restore /// instead of failing the whole workspace file. Never saved. Unknown, @@ -73,15 +75,7 @@ pub struct TabSpec { impl TabSpec { pub fn new(profile_id: Uuid, kind: TabKind) -> Self { - Self { - profile_id, - kind, - title: None, - cwd: None, - with_sftp: false, - sftp_local_cwd: None, - sftp_show_local: None, - } + Self { profile_id, kind, title: None, cwd: None, with_sftp: false, sftp_local_cwd: None, sftp_show_local: None } } } @@ -345,10 +339,7 @@ mod tests { #[test] fn cwd_round_trips_and_stays_optional() { - let with_cwd = TabSpec { - cwd: Some(r"D:\Projects\Personal\tether".into()), - ..spec(Uuid::nil(), TabKind::Local { shell_id: "pwsh".into() }) - }; + let with_cwd = TabSpec { cwd: Some(r"D:\Projects\Personal\tether".into()), ..spec(Uuid::nil(), TabKind::Local { shell_id: "pwsh".into() }) }; let text = serde_json::to_string(&with_cwd).unwrap(); assert!(text.contains(r"D:\\Projects\\Personal\\tether"), "{text}"); assert_eq!(serde_json::from_str::(&text).unwrap(), with_cwd); diff --git a/tests/codex_integration.rs b/tests/codex_integration.rs new file mode 100644 index 0000000..94a3ac7 --- /dev/null +++ b/tests/codex_integration.rs @@ -0,0 +1,79 @@ +//! Optional local integration: a real Codex CLI on a PTY, process/resource detection, +//! then an official-format completion payload through Tether's hook and IPC adapter. +//! No model prompt is submitted and the CLI uses a disposable CODEX_HOME. +#![cfg(windows)] + +use std::time::{Duration, Instant}; + +use tether::agents::{AgentHub, AgentKind, AgentState, hook, hub::TermInfo, ipc}; +use tether::pty::shells::{ShellIcon, ShellSpec}; +use tether::ssh::terminal::TerminalHandle; + +struct CloseOnDrop(TerminalHandle); +impl Drop for CloseOnDrop { + fn drop(&mut self) { + self.0.close(); + } +} + +#[test] +#[ignore = "requires TETHER_CODEX_E2E_BIN pointing to an installed Codex CLI"] +fn real_codex_session_and_completion_notification() { + let bin = std::env::var_os("TETHER_CODEX_E2E_BIN").expect("Codex CLI executable"); + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("codex-home"); + std::fs::create_dir(&home).unwrap(); + // This binary has one test and owns the environment for its child CLI. + unsafe { + std::env::set_var("TETHER_CONFIG_DIR", dir.path()); + std::env::set_var("CODEX_HOME", &home); + } + let rt = tokio::runtime::Runtime::new().unwrap(); + let ctx = eframe::egui::Context::default(); + let mut hub = AgentHub::new(rt.handle(), &ctx); + hub.monitor().set_focused(true); + hub.monitor().set_interval(Duration::from_millis(300)); + let (handle, rx) = TerminalHandle::new(30, 100); + let close = CloseOnDrop(handle.clone()); + let spec = + ShellSpec { id: "codex-e2e".into(), name: "Codex CLI".into(), program: bin.into(), args: vec!["--no-alt-screen".into()], icon_kind: ShellIcon::Unix }; + let child_handle = handle.clone(); + let cwd = dir.path().to_path_buf(); + let runner = std::thread::spawn(move || tether::pty::run_local_pty(&spec, Some(cwd), child_handle, rx, |_| {})); + let frame = |hub: &mut AgentHub| { + hub.frame(&ctx, vec![TermInfo { uid: 42, handle: &handle, visible: false, label: "Codex test", custom: None }], Instant::now()); + }; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + frame(&mut hub); + if hub.sessions().iter().any(|s| s.kind == AgentKind::Codex && s.usage.as_ref().is_some_and(|u| u.mem > 0)) { + break; + } + assert!(Instant::now() < deadline, "Codex was not detected: {}", handle.shared.lock().unwrap().parser.screen().contents()); + std::thread::sleep(Duration::from_millis(50)); + } + // Simulate the payload Codex supplies to notify, exercising the actual hook sender. + unsafe { + std::env::set_var("TETHER_SESSION", &handle.tag); + std::env::set_var("TETHER_IPC", ipc::endpoint().unwrap()); + } + let payload = + serde_json::json!({"type":"agent-turn-complete", "thread-id":"e2e-thread", "cwd":dir.path(), "last-assistant-message":"Completion adapter verified"}); + hook::run(&["codex".into(), payload.to_string()]); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + frame(&mut hub); + if hub.sessions().iter().any(|s| s.hooked && s.message.as_deref() == Some("Completion adapter verified")) { + break; + } + assert!(Instant::now() < deadline, "completion did not reach the session"); + std::thread::sleep(Duration::from_millis(25)); + } + assert_eq!(hub.sessions()[0].kind, AgentKind::Codex); + // Startup can still have active children; those hold Working until they finish. + assert!(matches!(hub.sessions()[0].state, AgentState::Done | AgentState::Working)); + assert!(tether::views::View::Codex.includes_agent(hub.sessions()[0].kind)); + assert!(!tether::views::View::Agents.includes_agent(hub.sessions()[0].kind)); + drop(close); + runner.join().unwrap().unwrap(); +} diff --git a/tests/general_updates.rs b/tests/general_updates.rs new file mode 100644 index 0000000..8ede3d4 --- /dev/null +++ b/tests/general_updates.rs @@ -0,0 +1,112 @@ +//! Exercises both sidebar custom launchers and the portable setup disclosure. +use eframe::egui::{self, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use tether::app::TetherApp; + +#[test] +fn custom_boot_and_export_flow() { + let config = tempfile::tempdir().unwrap(); + let project = config.path().join("PrismaCore project"); + std::fs::create_dir(&project).unwrap(); + // This test binary owns its isolated config and never loads the user's hosts. + unsafe { + std::env::set_var("TETHER_CONFIG_DIR", config.path()); + } + std::fs::write(config.path().join("settings.json"), r#"{"updates":{"check_on_startup":false},"agents":{"launch_shells":{"claude":"powershell"}}}"#) + .unwrap(); + let mut p = tether::profile::Profile::parse_quick("deploy@192.0.2.10:2222").unwrap(); + p.name = "Example machine".into(); + std::fs::write(config.path().join("profiles.json"), serde_json::to_string(&[p]).unwrap()).unwrap(); + tether::pty::shells::available(); + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 820.0)).with_max_steps(10_000).wgpu().build_eframe(|cc| TetherApp::new(cc).unwrap()); + h.run_steps(20); + h.get_by_label("Codex view").click(); + h.run_steps(5); + assert!(tether::settings::snapshot().layout.views.is_shown(tether::layout::View::Codex)); + h.get_by_label("Codex sessions"); + h.get_by_label("Launch Codex"); + shot(&mut h, "general-codex-view"); + h.get_by_label("Launch Codex").click(); + h.run_steps(5); + h.get_by_label("Codex · Launch"); + h.get_by_label("Project directory"); + shot(&mut h, "general-project-launch"); + h.get_by_label("Cancel").click(); + h.run_steps(5); + h.get_by_label("Codex view").click(); + h.run_steps(5); + for (launcher, title) in [("Agents view", "Claude Code · Custom Boot"), ("Codex view", "Codex · Custom Boot")] { + h.get_by_label(launcher).click_secondary(); + h.run_steps(5); + h.get_by_label("Custom Boot…").click(); + h.run_steps(5); + h.get_by_label(title); + if launcher == "Agents view" { + replace_command(&mut h, "claude -p"); + replace_field(&mut h, 1, &project.display().to_string()); + replace_field(&mut h, 2, "PrismaCore"); + h.get_by_label("☆ Save").click(); + h.run_steps(5); + assert!(tether::settings::snapshot().projects.entries.iter().any(|p| p.saved && p.name == "PrismaCore")); + let stored: tether::settings::Settings = serde_json::from_slice(&std::fs::read(config.path().join("settings.json")).unwrap()).unwrap(); + assert!(stored.projects.entries.iter().any(|p| p.saved && p.name == "PrismaCore")); + assert!(h.query_by_label("Initial prompt").is_none()); + h.get_by_label("Terminal shell"); + shot(&mut h, "general-account-flag"); + } + h.get_by_label("★ PrismaCore").click(); + h.run_steps(3); + h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().focus(); + h.key_press_modifiers(Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Key::A); + h.event(egui::Event::Text("echo custom_boot_ok".into())); + h.run_steps(3); + shot(&mut h, if launcher == "Agents view" { "general-claude-boot" } else { "general-codex-boot" }); + h.get_by_label("Start terminal").click(); + h.run_steps(15); + assert!(h.query_by_label(title).is_none()); + } + h.get_by_label("App menu").click(); + h.run_steps(5); + h.get_by_label_contains("Import / export setup").click(); + h.run_steps(5); + shot(&mut h, "general-import"); + h.get_by_label("Export setup").click(); + h.run_steps(5); + h.get_by_label_contains("Anyone with this file"); + h.get_by_label("I understand the file contains connection details"); + shot(&mut h, "general-export"); + h.get_by_label("I understand the file contains connection details").click(); + h.run_steps(5); + shot(&mut h, "general-export-acknowledged"); + h.get_by_label("Close").click(); + h.run_steps(5); + h.get_by_label("App menu").click(); + h.run_steps(5); + h.get_by_label_contains("Key manager").click(); + h.run_steps(5); + h.get_by_label("Search this PC for SSH keys…").click(); + h.run_steps(5); + h.get_by_label("Find an SSH key on this PC"); + h.get_by_label("Cancel").click(); + h.run_steps(5); + h.get_by_label("SSH keys"); + h.run_steps(5); +} + +fn shot(h: &mut Harness<'_, TetherApp>, name: &str) { + let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/ui-shots"); + std::fs::create_dir_all(&output).unwrap(); + h.render().unwrap().save(output.join(format!("{name}.png"))).unwrap(); +} + +fn replace_command(h: &mut Harness<'_, TetherApp>, text: &str) { + replace_field(h, 0, text); +} + +fn replace_field(h: &mut Harness<'_, TetherApp>, index: usize, text: &str) { + h.get_all_by_role(egui::accesskit::Role::TextInput).nth(index).unwrap().focus(); + h.key_press_modifiers(Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(5); +} diff --git a/tests/local_pty.rs b/tests/local_pty.rs index 4663fa3..785647d 100644 --- a/tests/local_pty.rs +++ b/tests/local_pty.rs @@ -3,8 +3,8 @@ use std::time::{Duration, Instant}; -use tether::pty::shells::{ShellIcon, ShellSpec}; use tether::pty::run_local_pty; +use tether::pty::shells::{ShellIcon, ShellSpec}; use tether::ssh::terminal::{LinkState, TerminalHandle}; const TIMEOUT: Duration = Duration::from_secs(20); @@ -71,3 +71,38 @@ fn shell_exit_reports_its_code() { assert_eq!(state, LinkState::Closed("Process exited with code 3".into())); runner.join().unwrap().unwrap(); } + +#[test] +#[cfg(windows)] +fn color_capabilities_and_ansi_survive_the_real_pty() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("Project with spaces"); + std::fs::create_dir(&cwd).unwrap(); + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()); + let spec = ShellSpec { + id: "color-probe".into(), name: "Color probe".into(), icon_kind: ShellIcon::Cmd, + program: format!(r"{root}\System32\WindowsPowerShell\v1.0\powershell.exe").into(), + args: vec!["-NoLogo".into(), "-NoProfile".into(), "-Command".into(), + "[Console]::WriteLine('CAPS:'+$env:TERM+':'+$env:COLORTERM+':'+$env:FORCE_COLOR+':'+$env:NO_COLOR); [Console]::WriteLine('CWD:'+(Get-Location).Path); [Console]::WriteLine([char]27+'[31mANSI_RED'+[char]27+'[38;5;202mINDEXED'+[char]27+'[38;2;12;123;234mTRUECOLOR'+[char]27+'[0m')".into()], + }; + let (handle, rx) = TerminalHandle::new(24, 200); + run_local_pty(&spec, Some(cwd.clone()), handle.clone(), rx, |_| {}).unwrap(); + let shared = handle.shared.lock().unwrap(); + let screen = shared.parser.screen(); + let text = screen.contents(); + assert!(text.contains("CAPS:xterm-256color:truecolor:3:"), "{text}"); + assert!(!text.contains("CAPS:xterm-256color:truecolor:3:1"), "NO_COLOR leaked from the GUI process"); + assert!(text.contains(&format!("CWD:{}", cwd.display())), "{text}"); + let color_at = |needle: &str| { + for row in 0..24 { + let line = screen.contents_between(row, 0, row, 200); + if let Some(col) = line.find(needle) { + return screen.cell(row, col as u16).unwrap().fgcolor(); + } + } + panic!("missing {needle}: {text}"); + }; + assert_eq!(color_at("ANSI_RED"), vt100::Color::Idx(1)); + assert_eq!(color_at("INDEXED"), vt100::Color::Idx(202)); + assert_eq!(color_at("TRUECOLOR"), vt100::Color::Rgb(12, 123, 234)); +} diff --git a/tests/sftp_prompt.rs b/tests/sftp_prompt.rs new file mode 100644 index 0000000..4491f3e --- /dev/null +++ b/tests/sftp_prompt.rs @@ -0,0 +1,167 @@ +//! A real loopback SSH/SFTP transport with a virtual filesystem. No Docker, user +//! credentials, external hosts or files from the user's configuration are needed. +use russh::server::{Auth, ChannelOpenHandle, Msg, Server as _, Session}; +use russh::{Channel, ChannelId}; +use russh_sftp::protocol::{Attrs, File, FileAttributes, Handle, Name, Status, StatusCode, Version}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tether::ssh::{cwd, pool::Pool}; + +const HOME: &str = "/srv/custom homes/prisma"; +const TARGET: &str = "/srv/custom homes/prisma/servers/practice-servers/na-practice-01"; + +#[derive(Clone)] +struct Server(Arc>>); +impl russh::server::Server for Server { + type Handler = Ssh; + fn new_client(&mut self, _: Option) -> Ssh { + Ssh { channels: HashMap::new(), seen: self.0.clone() } + } +} +struct Ssh { + channels: HashMap>, + seen: Arc>>, +} +impl russh::server::Handler for Ssh { + type Error = anyhow::Error; + async fn auth_password(&mut self, user: &str, password: &str) -> anyhow::Result { + assert_eq!(user, "root"); + assert_eq!(password, "fixture-only"); + Ok(Auth::Accept) + } + async fn channel_open_session(&mut self, channel: Channel, reply: ChannelOpenHandle, _: &mut Session) -> anyhow::Result<()> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + async fn exec_request(&mut self, channel: ChannelId, command: &[u8], session: &mut Session) -> anyhow::Result<()> { + // The home lookup must name the account printed in the prompt, not root. + assert_eq!(std::str::from_utf8(command)?, cwd::user_home_command("prisma")?); + self.seen.lock().unwrap().push("home:prisma".into()); + session.channel_success(channel)?; + session.data(channel, format!("prisma:x:1001:1001::{HOME}:/bin/bash\n").into_bytes())?; + session.exit_status_request(channel, 0)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) + } + async fn subsystem_request(&mut self, channel: ChannelId, name: &str, session: &mut Session) -> anyhow::Result<()> { + assert_eq!(name, "sftp"); + let channel_stream = self.channels.remove(&channel).unwrap().into_stream(); + let sftp = Files { read: HashSet::new(), seen: self.seen.clone() }; + session.channel_success(channel)?; + tokio::spawn(async move { + russh_sftp::server::run(channel_stream, sftp).await; + }); + Ok(()) + } +} +struct Files { + read: HashSet, + seen: Arc>>, +} +fn dir_attrs() -> FileAttributes { + FileAttributes { permissions: Some(0o040755), ..FileAttributes::empty() } +} +fn exists(path: &str) -> bool { + matches!(path, "/root" | TARGET | "/srv/second folder") +} +impl russh_sftp::server::Handler for Files { + type Error = StatusCode; + fn unimplemented(&self) -> StatusCode { + StatusCode::OpUnsupported + } + async fn init(&mut self, _: u32, _: HashMap) -> Result { + Ok(Version::new()) + } + async fn realpath(&mut self, id: u32, path: String) -> Result { + let path = if path == "." { "/root".into() } else { path }; + if !exists(&path) { + return Err(StatusCode::NoSuchFile); + } + Ok(Name { id, files: vec![File::new(path, dir_attrs())] }) + } + async fn stat(&mut self, id: u32, path: String) -> Result { + if !exists(&path) { + return Err(StatusCode::NoSuchFile); + } + Ok(Attrs { id, attrs: dir_attrs() }) + } + async fn opendir(&mut self, id: u32, path: String) -> Result { + if !exists(&path) { + return Err(StatusCode::NoSuchFile); + } + self.seen.lock().unwrap().push(path.clone()); + self.read.remove(&path); + Ok(Handle { id, handle: path }) + } + async fn readdir(&mut self, id: u32, handle: String) -> Result { + if !self.read.insert(handle.clone()) { + return Err(StatusCode::Eof); + } + let marker = if handle == TARGET { "clicked-directory-marker" } else { "other-directory" }; + Ok(Name { id, files: vec![File::new(marker, dir_attrs())] }) + } + async fn close(&mut self, id: u32, _: String) -> Result { + Ok(Status { id, status_code: StatusCode::Ok, error_message: String::new(), language_tag: String::new() }) + } +} + +#[test] +fn root_connection_browses_the_switched_users_prompt_directory() { + let config = tempfile::tempdir().unwrap(); + // One test owns this binary; its history and settings never touch the user's config. + unsafe { + std::env::set_var("TETHER_CONFIG_DIR", config.path()); + } + tether::settings::init(config.path().join("settings.json")); + let rt = tokio::runtime::Runtime::new().unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let listener = rt.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = tether::keystore::generate(tether::keystore::KeyKind::Ed25519, "loopback test").unwrap(); + let server_config = Arc::new(russh::server::Config { keys: vec![key], ..Default::default() }); + let server_seen = seen.clone(); + let server = rt.spawn(async move { + Server(server_seen).run_on_socket(server_config, &listener).await.unwrap(); + }); + let profile = tether::profile::Profile::parse_quick(&format!("root@127.0.0.1:{port}")).unwrap(); + let pool = Pool::new(tether::known_hosts::KnownHosts::load(config.path().join("known_hosts.json"))); + rt.block_on(pool.get(&profile, Some("fixture-only".into()))).unwrap(); + let prompt = "prisma@na-prisma-02:~/servers/practice-servers/na-practice-01$"; + let (_, target, _) = tether::ui::term_links::find_link_in_str(prompt, prompt.find("servers").unwrap(), None, false, &mut Default::default()).unwrap(); + let tether::ui::term_links::LinkTarget::RemotePath { path, .. } = target else { panic!("not a remote path") }; + let request = Arc::new(Mutex::new(Some(path))); + let requests = request.clone(); + let runtime = rt.handle().clone(); + let mut view = None; + let mut h = egui_kittest::Harness::builder().with_size(eframe::egui::vec2(1000.0, 650.0)).build_ui(move |ui| { + let view = view.get_or_insert_with(|| { + let view = tether::ui::sftp_view::SftpView::new(profile.clone(), runtime.clone(), ui.ctx().clone()); + view.connect(pool.clone(), None, Default::default()); + view + }); + if let Some(path) = requests.lock().unwrap().take() { + view.browse_path(path); + } + view.ui(ui); + }); + for expected in [TARGET, "/srv/second folder"] { + let until = Instant::now() + Duration::from_secs(15); + loop { + h.run_steps(3); + if seen.lock().unwrap().iter().any(|p| p == expected) { + break; + } + assert!(Instant::now() < until, "SFTP didn't navigate to {expected}; requests: {:?}", seen.lock().unwrap()); + std::thread::sleep(Duration::from_millis(20)); + } + *request.lock().unwrap() = Some("/srv/second folder".into()); + } + assert!(seen.lock().unwrap().iter().any(|p| p == "home:prisma")); + assert!(!seen.lock().unwrap().iter().any(|p| p.starts_with("/root/servers"))); + drop(h); + server.abort(); + rt.shutdown_timeout(Duration::from_secs(1)); +} diff --git a/tests/shell_integration.rs b/tests/shell_integration.rs index e12c6c7..3475ea3 100644 --- a/tests/shell_integration.rs +++ b/tests/shell_integration.rs @@ -61,6 +61,33 @@ fn local_shell_reports_blocks_with_exit_codes_and_cwd() { scenario(shell(), FAIL_3); } +#[test] +fn shift_enter_inserts_a_line_and_clear_removes_old_blocks() { + let (handle, rx) = TerminalHandle::new(30, 120); + let spec = shell(); + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().to_path_buf(); + let h = handle.clone(); + let runner = std::thread::spawn(move || run_local_pty(&spec, Some(cwd), h, rx, |_| {})); + wait_for(&handle, "prompt", |b| b.last().is_some_and(|b| b.phase == Phase::Prompt)); + handle.send(b"echo first_line".to_vec()); + let shifted = tether::ui::keys::encode(eframe::egui::Key::Enter, eframe::egui::Modifiers::SHIFT, false).unwrap(); + handle.send(shifted); + std::thread::sleep(Duration::from_millis(500)); + assert!(done(&blocks(&handle)).is_empty(), "Shift+Enter submitted the command"); + handle.send(b"echo second_line\r".to_vec()); + wait_for(&handle, "multiline command", |b| !done(b).is_empty()); + let command = blocks(&handle).into_iter().find(|b| b.phase == Phase::Done).unwrap().command.unwrap(); + assert!(command.contains("first_line") && command.contains("second_line") && command.contains('\n'), "{command:?}"); + #[cfg(windows)] + handle.send(b"cls\r".to_vec()); + #[cfg(unix)] + handle.send(b"clear\r".to_vec()); + wait_for(&handle, "clear to erase old decorations", |b| b.iter().all(|b| b.command.as_deref().is_none_or(|c| !c.contains("first_line")))); + handle.close(); + runner.join().unwrap().unwrap(); +} + /// Git Bash through ConPTY (skipped when Git for Windows is not installed). #[cfg(windows)] #[test] diff --git a/tests/ui_flow.rs b/tests/ui_flow.rs index cdc79c8..2ddb89c 100644 --- a/tests/ui_flow.rs +++ b/tests/ui_flow.rs @@ -79,7 +79,7 @@ fn ui_flows() { // Import dialog (auto-detects Bitvise profiles / SSH config on this machine). h.get_by_label_contains("Import from Bitvise").click(); h.run_steps(10); - h.get_by_label("Import hosts"); + h.get_by_label("Import / export machines"); shot(&mut h, "02b-import"); h.get_by_label("Cancel").click(); h.run_steps(10); @@ -178,7 +178,7 @@ fn sidebar_menus(h: &mut Harness<'_, TetherApp>) { h.get_by_label("New menu").click(); h.run_steps(10); - h.get_by_label_contains("Import hosts"); + h.get_by_label_contains("Import / export setup"); shot(h, "09-new-menu"); h.key_press(Key::Escape); h.run_steps(10);