From 1af615cc2af1714ed68a2e59c29845344dc27475 Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Wed, 21 Oct 2026 19:56:53 -0500 Subject: [PATCH 1/7] chore: satisfy current Rust formatting and lint checks --- src/actions.rs | 9 +- src/agents/hub.rs | 15 +- src/agents/install.rs | 3 +- src/agents/state.rs | 18 +- src/agents/title.rs | 8 +- src/app_layout.rs | 38 ++-- src/app_local.rs | 41 +---- src/crashlog.rs | 5 +- src/lib.rs | 2 +- src/procmon/mod.rs | 15 +- src/procmon/tree.rs | 10 +- src/pty/shells.rs | 11 +- src/shell_integration/mod.rs | 5 +- src/ui/agent_status.rs | 10 +- src/ui/blocks.rs | 12 +- src/ui/detach.rs | 12 +- src/ui/layout_settings.rs | 41 ++++- src/ui/mini_dock.rs | 342 +++++++++++++++-------------------- src/ui/palette.rs | 28 ++- src/ui/sftp_dialogs.rs | 31 +--- src/ui/sftp_view.rs | 18 +- src/ui/tabs.rs | 57 ++---- src/ui/term_grid.rs | 5 +- src/ui/term_links.rs | 74 ++------ src/ui/terminal_bench.rs | 14 +- src/ui/vertical_tabs.rs | 45 ++++- src/ui/welcome.rs | 30 ++- src/workspace.rs | 19 +- tests/local_pty.rs | 2 +- 29 files changed, 397 insertions(+), 523 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 22500a9..8f91a88 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -40,7 +40,11 @@ pub enum Command { /// 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 }, + LaunchAgent { + kind: crate::agents::AgentKind, + command: &'static str, + pick_folder: bool, + }, /// Anything else: runs on the UI thread when chosen. Run(Arc), } @@ -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..0c98915 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 { 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..f205b0a 100644 --- a/src/agents/state.rs +++ b/src/agents/state.rs @@ -201,9 +201,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, @@ -265,11 +263,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 } } } } @@ -319,13 +313,7 @@ impl Tracker { } // Only let `✳` (title_busy == Some(false)) mark Done when hooks are NOT active // and no child processes or subagents are still running. - Some(false) - if prev_busy == Some(true) - && self.state == AgentState::Working - && !self.hooked - && !obs.subprocs_busy - && self.active_subagents == 0 => - { + Some(false) if prev_busy == Some(true) && self.state == AgentState::Working && !self.hooked && !obs.subprocs_busy && self.active_subagents == 0 => { return self.set(AgentState::Done, 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_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/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/lib.rs b/src/lib.rs index e3de323..564c895 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,8 +19,8 @@ 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 theme; pub mod theme_catalog; 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/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/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/ui/agent_status.rs b/src/ui/agent_status.rs index 61fa063..dc0424d 100644 --- a/src/ui/agent_status.rs +++ b/src/ui/agent_status.rs @@ -289,12 +289,18 @@ 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)); 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/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/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/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_view.rs b/src/ui/sftp_view.rs index b35036f..fa4fa2d 100644 --- a/src/ui/sftp_view.rs +++ b/src/ui/sftp_view.rs @@ -165,15 +165,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), @@ -494,13 +486,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() }); }); } diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 434df33..8f02060 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, @@ -442,7 +434,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 +469,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); @@ -506,9 +495,7 @@ impl egui_dock::TabViewer for Viewer { match &mut tab.body { TabBody::Terminal(view) => { 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 { @@ -566,11 +553,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 +565,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())); @@ -754,9 +729,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 +744,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); 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..64f435b 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). @@ -77,17 +69,12 @@ pub enum LinkAction { } /// 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 +118,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; @@ -200,11 +179,9 @@ impl LinksState { } LinkTarget::RemotePath { path, .. } => format!("Remote: {path}\nClick 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) @@ -451,10 +428,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 +448,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)?; @@ -573,11 +540,7 @@ fn classify_candidate( 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 { @@ -646,7 +609,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 { @@ -698,11 +664,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 +718,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(), } 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/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/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/local_pty.rs b/tests/local_pty.rs index 4663fa3..89793e8 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); From ceed433fcf9e3b75a31ee8bb046bd940f0ef5009 Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Wed, 21 Oct 2026 20:04:44 -0500 Subject: [PATCH 2/7] feat: improve terminal input, agent launchers and SSH setup portability --- Cargo.lock | 2 + Cargo.toml | 4 +- README.md | 12 ++ src/app.rs | 36 ++-- src/app_agents.rs | 30 ++- src/app_chrome.rs | 14 +- src/app_palette.rs | 14 +- src/app_views.rs | 24 ++- src/import/discovery.rs | 122 ++++++++++++ src/import/mod.rs | 4 + src/import/setup.rs | 98 ++++++++++ src/shell_integration/bash.sh | 2 + src/shell_integration/blocks.rs | 21 +- src/shell_integration/fish.fish | 1 + src/shell_integration/pwsh.ps1 | 5 + src/shell_integration/zsh.zsh | 3 + src/ssh/terminal.rs | 76 ++++++++ src/ui/activity.rs | 85 +++++++- src/ui/agent_icons.rs | 16 +- src/ui/agents_panel.rs | 90 +++++++-- src/ui/attachments.rs | 333 ++++++++++++++++++++++++++++++++ src/ui/editor.rs | 4 +- src/ui/editor/auth.rs | 12 ++ src/ui/import_view.rs | 114 ++++++++++- src/ui/key_manager.rs | 22 ++- src/ui/key_search.rs | 96 +++++++++ src/ui/keys.rs | 9 + src/ui/mod.rs | 2 + src/ui/os_input.rs | 4 +- src/ui/sidebar/menu.rs | 4 +- src/ui/terminal_view.rs | 126 ++++++++---- tests/general_updates.rs | 65 +++++++ tests/shell_integration.rs | 27 +++ tests/ui_flow.rs | 4 +- 34 files changed, 1359 insertions(+), 122 deletions(-) create mode 100644 src/import/discovery.rs create mode 100644 src/import/setup.rs create mode 100644 src/ui/attachments.rs create mode 100644 src/ui/key_search.rs create mode 100644 tests/general_updates.rs diff --git a/Cargo.lock b/Cargo.lock index 0937613..85f4636 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4947,6 +4947,7 @@ name = "tether" version = "0.2.2" dependencies = [ "anyhow", + "arboard", "directories", "eframe", "egui_dock", @@ -4976,6 +4977,7 @@ dependencies = [ "ureq", "uuid", "vt100", + "vte", "windows-sys 0.61.2", "winreg 0.56.0", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 53b3832..ed45f63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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..21606eb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,18 @@ 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**: use **+ Image**, paste a screenshot or drop PNG/JPEG files onto a terminal. Colored **[Image 1]** labels show a thumbnail on hover. Tether inserts a usable file path for the shell/agent; SSH terminals upload the image over SFTP first into a private directory under the remote user's home. Local temporary copies are removed when the terminal closes. Remote copies remain available to the agent. **Clear previews** only hides the thumbnails; edit the prompt in the terminal to remove an inserted path. +- **Claude Code / Codex**: the sidebar offers a Codex terminal shortcut alongside the Claude agent icon. Right-click either icon and choose **Custom Boot…** to enter a command such as `claude -p` or `codex --help`. The command runs in a new default shell. Both agents are also available in the Agents panel's empty state. + +### 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. Use **Import Tether setup…**, 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/src/app.rs b/src/app.rs index ac19114..85c26ba 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,7 @@ pub struct TetherApp { broadcast: bool, editor: Option, import: Option, + custom_boot: Option, settings_view: Option, key_manager: Option, prompt: Option, @@ -176,6 +177,7 @@ impl TetherApp { detached: Detacher::default(), mini_dock: Default::default(), agents, + custom_boot: None, }) } @@ -212,10 +214,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 +245,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 +275,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 { @@ -331,13 +333,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; }; @@ -657,11 +653,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; } @@ -782,6 +774,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 +1003,3 @@ fn is_no_such_file(err: &anyhow::Error) -> bool { ) }) } - diff --git a/src/app_agents.rs b/src/app_agents.rs index 5e98f24..84e7490 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(), + }); } } } @@ -163,7 +169,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 +204,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,6 +217,22 @@ 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, pick_folder: false })), ctx); + } + + pub(super) fn custom_boot_modal(&mut self, ctx: &egui::Context) { + let Some(boot) = &mut self.custom_boot else { return }; + let kind = boot.kind; + let (command, close) = boot.show(ctx); + if close { + self.custom_boot = None; + } + if let Some(command) = command { + self.launch_agent_command(kind, 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 { diff --git a/src/app_chrome.rs b/src/app_chrome.rs index 6f82304..195c201 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,7 +137,7 @@ 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)); } if item(ui, "Key manager…", Some(Action::KeyManager)) { @@ -180,7 +183,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 +216,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_palette.rs b/src/app_palette.rs index a9dbe7a..c05bdac 100644 --- a/src/app_palette.rs +++ b/src/app_palette.rs @@ -155,11 +155,12 @@ impl TetherApp { 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(), - }); + let pick = crate::ui::agents_panel::LaunchPick { kind, command: command.into(), 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() }); self.open_agents_in(vec![(target, pick)], ctx); } Command::Run(f) => f(ctx), @@ -168,8 +169,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() { @@ -242,7 +243,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..ca5d101 100644 --- a/src/app_views.rs +++ b/src/app_views.rs @@ -98,7 +98,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); @@ -207,6 +213,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 +232,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 +326,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/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/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/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/terminal.rs b/src/ssh/terminal.rs index 99704d4..21ff65a 100644 --- a/src/ssh/terminal.rs +++ b/src/ssh/terminal.rs @@ -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; @@ -350,6 +400,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/ui/activity.rs b/src/ui/activity.rs index e8f75f1..adb5022 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. @@ -133,6 +139,25 @@ impl Strip<'_> { self.item(ui, v, r, rect, events); items.push(r); } + if self.container == Container::Primary { + let r = if vertical { + Rect::from_min_size(Pos2::new(rect.center().x - ITEM / 2.0, cursor), Vec2::splat(ITEM)) + } else { + Rect::from_min_size(Pos2::new(cursor, rect.center().y - ROW_ITEM / 2.0), Vec2::splat(ROW_ITEM)) + }; + let kind = crate::agents::AgentKind::Codex; + let resp = ui.interact(r, Id::new("codex_launcher"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Launch Codex")); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(8), theme::surface_2()); + } + super::agent_icons::paint(ui.painter(), Rect::from_center_size(r.center(), Vec2::splat(GLYPH)), kind, 1.0); + if resp.clicked() { + events.push(ViewEvent::LaunchAgent(kind)); + } + resp.context_menu(|ui| agent_boot_menu(ui, kind, events)); + resp.on_hover_text("Launch Codex Β· Right-click for Custom Boot"); + } targets.strips.push(StripTarget { container: self.container, rect, vertical, items, views: self.state.views.clone() }); } @@ -201,7 +226,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 +239,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 v == View::Agents { + agent_boot_menu(ui, crate::agents::AgentKind::Claude, 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 +282,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 +326,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 +391,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 +413,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 +502,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..c09d9d7 100644 --- a/src/ui/agent_icons.rs +++ b/src/ui/agent_icons.rs @@ -1,5 +1,5 @@ //! 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, +//! starburst, Codex's terminal mark and a sparkle for the rest. Drawn with the painter, //! so they stay crisp at 14–16 px and in every 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,12 @@ 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))); +/// Codex's familiar >_ terminal mark, readable even in the activity strip. +fn codex_mark(p: &Painter, center: Pos2, radius: f32, c: Color32) { + let stroke = Stroke::new((radius * 0.2).clamp(1.2, 2.0), c); + let at = |x, y| center + Vec2::new(x * radius, y * radius); + p.add(Shape::line(vec![at(-0.85, -0.6), at(-0.15, 0.0), at(-0.85, 0.6)], stroke)); + p.line_segment([at(0.15, 0.6), at(0.9, 0.6)], stroke); } /// A four-point star with a small companion. diff --git a/src/ui/agents_panel.rs b/src/ui/agents_panel.rs index 49144ce..b41d52a 100644 --- a/src/ui/agents_panel.rs +++ b/src/ui/agents_panel.rs @@ -11,6 +11,52 @@ 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, + focus: bool, +} + +impl CustomBoot { + pub fn new(kind: AgentKind) -> Self { + Self { kind, command: default_command(kind).into(), focus: true } + } + + pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { + let mut command = None; + let mut close = false; + let modal = egui::Modal::new(egui::Id::new("custom_agent_boot")).show(ctx, |ui| { + ui.set_width(440.0); + ui.horizontal(|ui| { + super::agent_icons::icon(ui, self.kind, 24.0); + ui.heading(format!("{} Β· Custom Boot", self.kind.name())); + }); + ui.label("Enter the command to run in a new terminal."); + ui.add_space(8.0); + let response = ui.add(egui::TextEdit::singleline(&mut self.command).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY)); + if std::mem::take(&mut self.focus) { + response.request_focus(); + } + let valid = !self.command.trim().is_empty() && !self.command.contains(['\r', '\n', '\0']); + ui.add_space(8.0); + ui.horizontal(|ui| { + let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui.add_enabled(valid, egui::Button::new("Start terminal").fill(theme::accent_soft())).clicked() || (valid && enter) { + command = Some(self.command.trim().to_owned()); + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + (command, close || modal.should_close()) + } +} const ROW_H: f32 = 64.0; pub enum PanelAction { @@ -20,13 +66,14 @@ 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, + pub command: String, /// Ask for the folder to start in first. pub pick_folder: bool, } @@ -43,10 +90,10 @@ pub fn launch_menu(ui: &mut egui::Ui) -> Option { 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 }); + pick = Some(LaunchPick { kind: l.kind, command: l.command.into(), 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 }); + pick = Some(LaunchPick { kind: l.kind, command: l.command.into(), pick_folder: true }); } }); } @@ -76,7 +123,7 @@ 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 { @@ -123,15 +170,32 @@ fn empty_state(ui: &mut egui::Ui) -> Option { 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 })); + for kind in [AgentKind::Claude, AgentKind::Codex] { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(width, 30.0), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(7), if resp.hovered() { theme::accent_soft() } else { theme::surface_3() }); + super::agent_icons::paint( + ui.painter(), + Rect::from_center_size(egui::pos2(rect.left() + 16.0, rect.center().y), Vec2::splat(16.0)), + kind, + 1.0, + ); + ui.painter().text( + egui::pos2(rect.left() + 32.0, rect.center().y), + Align2::LEFT_CENTER, + format!("Launch {}", kind.name()), + FontId::proportional(12.0), + theme::text(), + ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Launch {}", kind.name()))); + if resp.clicked() { + action = Some(PanelAction::Launch(LaunchPick { kind, command: default_command(kind).into(), pick_folder: false })); } + resp.context_menu(|ui| { + if ui.button("Custom Boot…").clicked() { + action = Some(PanelAction::CustomBoot(kind)); + ui.close(); + } + }); } 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"; diff --git a/src/ui/attachments.rs b/src/ui/attachments.rs new file mode 100644 index 0000000..a232d2c --- /dev/null +++ b/src/ui/attachments.rs @@ -0,0 +1,333 @@ +//! 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, + name: String, + path: String, +} + +struct Prepared { + pixels: image::RgbaImage, + local: PathBuf, + path: String, + name: 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, name: path.file_name().unwrap_or_default().to_string_lossy().into_owned() }) + }); + 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, name }) => { + 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, name, path: path.clone() }); + 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(260.0); + ui.image((item.texture.id(), item.texture.size_vec2())); + ui.label(&item.name); + ui.add(egui::Label::new(RichText::new(&item.path).small().color(theme::text_dim())).wrap()); + }); + } + 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]); + assert_eq!(images.items[0].name, "reference.png"); + 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/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/import_view.rs b/src/ui/import_view.rs index 295a351..5eb8444 100644 --- a/src/ui/import_view.rs +++ b/src/ui/import_view.rs @@ -18,6 +18,10 @@ struct Row { pub struct ImportDialog { rows: Vec, notice: Option, + export: bool, + acknowledged: bool, + scan: Option>, + searched: Vec, } pub enum ImportOutcome { @@ -28,7 +32,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 +47,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,10 +85,41 @@ 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.heading("Import / export machines"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.export, false, "Import"); + ui.selectable_value(&mut self.export, true, "Export setup"); + }); + if self.export { + self.export_ui(ui, store); + if ui.button("Close").clicked() { + outcome = ImportOutcome::Cancel; + } + return; + } 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| { @@ -91,6 +130,38 @@ impl ImportDialog { self.pick_ssh_config(store); } }); + ui.horizontal(|ui| { + if ui.button("Import Tether setup…").clicked() { + if 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 ui.add_enabled(self.scan.is_none(), egui::Button::new("Search this PC")).clicked() { + let (tx, rx) = std::sync::mpsc::channel(); + self.scan = Some(rx); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(crate::import::discovery::scan(crate::import::discovery::roots())); + ctx.request_repaint(); + }); + } + if self.scan.is_some() { + ui.spinner(); + } + }); + if !self.searched.is_empty() { + ui.collapsing("Searched locations", |ui| { + for path in &self.searched { + ui.label(path.display().to_string()); + } + }); + } if let Some(n) = &self.notice { ui.label(RichText::new(n).size(12.0).color(theme::accent())); } @@ -115,6 +186,34 @@ impl ImportDialog { outcome } + fn export_ui(&mut self, ui: &mut Ui, store: &ProfileStore) { + ui.add_space(8.0); + ui.label( + RichText::new("Anyone with this file can import your machine names, IP addresses / hostnames, ports, usernames and colors.").color(theme::warn()), + ); + ui.label("SSH keys, key paths, passwords and passphrases are excluded. Imported machines require credentials to connect."); + ui.add_space(8.0); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for p in &store.profiles { + ui.label(format!("{} Β· {}", p.display_name(), p.address())); + } + }); + ui.checkbox(&mut self.acknowledged, "I understand the file contains connection details"); + if ui.add_enabled(self.acknowledged && !store.profiles.is_empty(), egui::Button::new(format!("Export {} machines…", store.profiles.len()))).clicked() + { + 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:#}"), + }); + } + } + if let Some(n) = &self.notice { + ui.label(n); + } + } + fn list(&mut self, ui: &mut Ui) { if self.rows.is_empty() { ui.add_space(20.0); @@ -126,15 +225,15 @@ impl ImportDialog { 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] { + for source in [Source::Tether, Source::Bitvise, Source::OpenSsh] { if !self.rows.iter().any(|r| r.candidate.source == source) { continue; } @@ -142,7 +241,7 @@ impl ImportDialog { 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.horizontal(|ui| { - ui.checkbox(&mut row.selected, ""); + ui.add_enabled(!row.exists, egui::Checkbox::new(&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())); @@ -152,6 +251,7 @@ impl ImportDialog { }) .response .on_hover_text(origin_label(&row.candidate.origin)); + ui.label(RichText::new(row.candidate.origin.display().to_string()).size(10.5).color(theme::text_faint())); } } }); 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..4812b41 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], @@ -118,6 +121,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"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 44131c6..188cd75 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; diff --git a/src/ui/os_input.rs b/src/ui/os_input.rs index 7f7a55f..06056f4 100644 --- a/src/ui/os_input.rs +++ b/src/ui/os_input.rs @@ -81,7 +81,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 +94,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`). 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/terminal_view.rs b/src/ui/terminal_view.rs index b01ca3e..e13c083 100644 --- a/src/ui/terminal_view.rs +++ b/src/ui/terminal_view.rs @@ -66,8 +66,6 @@ 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). @@ -82,6 +80,9 @@ pub struct TerminalView { /// Painted rows and the last header info seen. struct ViewCache { + /// 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, @@ -117,6 +118,10 @@ pub struct ViewOutput { } impl TerminalView { + pub fn attachments(&mut self) -> &mut super::attachments::Attachments { + &mut self.cache.attachments + } + pub fn new(handle: TerminalHandle) -> Self { Self { handle, @@ -132,13 +137,14 @@ 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 { grid: GridCache::default(), + typed_line: String::new(), + attachments: Default::default(), title: None, cwd: None, link: LinkState::Connecting, @@ -185,7 +191,9 @@ 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_rect = Rect::from_min_max(egui::pos2(full_rect.left(), (full_rect.bottom() - 34.0).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 +234,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 !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 { @@ -290,6 +304,7 @@ impl TerminalView { } self.connecting_screen(ui, rect, &state, stage, profile, now); out.reconnect = state_overlay(ui, rect, &state); + self.cache.attachments.bar(ui, bar_rect); if !out.input.is_empty() { self.scroll = 0; self.selection = None; @@ -300,14 +315,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; @@ -536,9 +544,7 @@ 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; @@ -593,17 +599,30 @@ 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) => { + self.ctrl_v_handled = true; + let files = super::os_input::clipboard_files(); + if 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 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 +633,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 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 +654,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 +688,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(_))); @@ -679,22 +703,28 @@ impl TerminalView { } if !self.ctrl_v_handled && (down || released_v) { self.ctrl_v_handled = true; - self.paste_clipboard_non_text(bracketed, out); + self.paste_clipboard_non_text(ui.ctx(), bracketed, out); } if !down && !had_paste { self.ctrl_v_handled = false; } } - 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| 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"); + self.cache.attachments.paste_image(ctx); } } @@ -713,7 +743,12 @@ 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(); } @@ -721,7 +756,9 @@ impl TerminalView { return false; } 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| 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 +807,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 +922,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 +979,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; } diff --git a/tests/general_updates.rs b/tests/general_updates.rs new file mode 100644 index 0000000..2d4f401 --- /dev/null +++ b/tests/general_updates.rs @@ -0,0 +1,65 @@ +//! 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(); + // 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}}"#).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("Launch Codex"); + for (launcher, title) in [("Agents view", "Claude Code Β· Custom Boot"), ("Launch Codex", "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); + h.get_by_role(egui::accesskit::Role::TextInput).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); + 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("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(); +} 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); From cda92741b0e31bd627ad49fd301710209bbe3cba Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Wed, 21 Oct 2026 21:19:33 -0500 Subject: [PATCH 3/7] feat: add a dedicated Codex view and refine setup dialogs --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 5 +- src/actions.rs | 3 +- src/agents/hub.rs | 18 +++ src/agents/launch.rs | 80 ++++++++++ src/agents/state.rs | 41 +++++- src/app_chrome.rs | 4 + src/app_palette.rs | 3 + src/app_views.rs | 38 +++-- src/keybindings.rs | 8 +- src/layout.rs | 2 +- src/ui/activity.rs | 30 +--- src/ui/agent_status.rs | 5 +- src/ui/agents_panel.rs | 178 +++++++++++++---------- src/ui/attachments.rs | 14 +- src/ui/form.rs | 9 ++ src/ui/import_view.rs | 290 ++++++++++++++++++++++++------------- src/ui/settings_agents.rs | 6 +- src/views.rs | 53 ++++++- tests/codex_integration.rs | 79 ++++++++++ tests/general_updates.rs | 28 +++- 22 files changed, 653 insertions(+), 243 deletions(-) create mode 100644 tests/codex_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 85f4636..b0c5b7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4968,6 +4968,7 @@ dependencies = [ "serde_json", "serde_norway", "sha2", + "shell-words", "ssh-key", "sysinfo", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index ed45f63..1e2b2eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ vt100 = "0.16.2" vte = "0.15" arboard = "3.7" tempfile = "3" +shell-words = "1.1" zeroize = "1" portable-pty = "0.9" serde_norway = "0.9.42" diff --git a/README.md b/README.md index 21606eb..4329074 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B - **Shift+Enter** inserts a line without submitting in Claude Code, Codex and integrated PowerShell, Bash, Zsh and Fish prompts. Enter submits normally. - **Images**: use **+ Image**, paste a screenshot or drop PNG/JPEG files onto a terminal. Colored **[Image 1]** labels show a thumbnail on hover. Tether inserts a usable file path for the shell/agent; SSH terminals upload the image over SFTP first into a private directory under the remote user's home. Local temporary copies are removed when the terminal closes. Remote copies remain available to the agent. **Clear previews** only hides the thumbnails; edit the prompt in the terminal to remove an inserted path. -- **Claude Code / Codex**: the sidebar offers a Codex terminal shortcut alongside the Claude agent icon. Right-click either icon and choose **Custom Boot…** to enter a command such as `claude -p` or `codex --help`. The command runs in a new default shell. Both agents are also available in the Agents panel's empty state. +- **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 to enter a command for a new default shell. Interactive commands such as `claude --dangerously-skip-permissions` keep their original arguments. Commands such as `claude -p` or `codex exec` without input show a required **Initial prompt** field, or you can choose **Use interactive command**. ### 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. Use **Import Tether setup…**, review the selected machines and import them; existing endpoints are skipped and imported machines need credentials before connecting. +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. diff --git a/src/actions.rs b/src/actions.rs index 8f91a88..6add539 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -211,7 +211,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", _ => "", } } diff --git a/src/agents/hub.rs b/src/agents/hub.rs index 0c98915..e0666e1 100644 --- a/src/agents/hub.rs +++ b/src/agents/hub.rs @@ -558,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/launch.rs b/src/agents/launch.rs index d2a3f9f..3dfb813 100644 --- a/src/agents/launch.rs +++ b/src/agents/launch.rs @@ -6,6 +6,71 @@ use std::sync::OnceLock; use super::AgentKind; +/// Definite missing-input cases, without rewriting the command the user entered. +pub fn needs_initial_prompt(command: &str) -> bool { + let Ok(words) = shell_words::split(command) else { return false }; + let Some(program) = words.first() else { return false }; + let program = program.rsplit(['/', '\\']).next().unwrap_or(program).trim_end_matches(['.', ' ']).to_ascii_lowercase(); + let claude = ["claude", "claude.exe", "claude.cmd"].contains(&program.as_str()); + let codex = ["codex", "codex.exe", "codex.cmd"].contains(&program.as_str()); + if words.iter().any(|s| s == "--help" || s == "-h" || s == "--version") { + return false; + } + if !(claude && words.iter().any(|s| s == "-p" || s == "--print") || codex && words.get(1).is_some_and(|s| s == "exec")) { + return false; + } + let mut skip = false; + for word in words.iter().skip(if codex { 2 } else { 1 }) { + if skip { + skip = false; + continue; + } + if [ + "--model", + "-m", + "--output-format", + "--input-format", + "--permission-mode", + "--allowedTools", + "--disallowedTools", + "--add-dir", + "--system-prompt", + "--append-system-prompt", + "--max-turns", + "--max-budget-usd", + "--resume", + "-r", + "--cwd", + "-C", + "--config", + "-c", + "--profile", + "--sandbox", + "--output-last-message", + "-o", + ] + .contains(&word.as_str()) + { + skip = true; + continue; + } + if !word.starts_with('-') && !word.is_empty() { + return false; + } + } + true +} + +/// Literal shell argument for the optional initial prompt. Custom commands themselves +/// intentionally remain user-authored shell syntax. +pub fn append_initial_prompt(command: &str, prompt: &str, powershell: bool) -> String { + if prompt.is_empty() { + return command.trim().into(); + } + let quoted = if powershell { format!("'{}'", prompt.replace('\'', "''")) } else { shell_words::quote(prompt).into_owned() }; + format!("{} {quoted}", command.trim()) +} + #[derive(Debug, Clone)] pub struct Launcher { pub kind: AgentKind, @@ -61,6 +126,21 @@ fn is_file(p: &Path) -> bool { mod tests { use super::*; + #[test] + fn print_modes_require_real_input_but_interactive_commands_do_not() { + for command in ["claude -p", "claude --print --model sonnet", "claude -p --dangerously-skip-permissions", "codex exec"] { + assert!(needs_initial_prompt(command), "{command}"); + } + for command in + ["claude", "claude --dangerously-skip-permissions", "claude -p 'hello'", "claude -p --model sonnet 'hello'", "codex", "codex exec --help"] + { + assert!(!needs_initial_prompt(command), "{command}"); + } + assert_eq!(append_initial_prompt("claude -p", "Don't run $(anything)", true), "claude -p 'Don''t run $(anything)'"); + let command = append_initial_prompt("claude -p", "hello 'quoted' world", false); + assert_eq!(shell_words::split(&command).unwrap().last().unwrap(), "hello 'quoted' world"); + } + #[test] fn finds_commands_with_extensions_in_path_order() { let a = tempfile::tempdir().unwrap(); diff --git a/src/agents/state.rs b/src/agents/state.rs index f205b0a..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, @@ -228,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; @@ -313,7 +321,13 @@ impl Tracker { } // Only let `✳` (title_busy == Some(false)) mark Done when hooks are NOT active // and no child processes or subagents are still running. - Some(false) if prev_busy == Some(true) && self.state == AgentState::Working && !self.hooked && !obs.subprocs_busy && self.active_subagents == 0 => { + Some(false) + if prev_busy == Some(true) + && self.state == AgentState::Working + && !self.lifecycle_hooks + && !obs.subprocs_busy + && self.active_subagents == 0 => + { return self.set(AgentState::Done, now); } _ => {} @@ -328,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) { @@ -360,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 { @@ -409,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/app_chrome.rs b/src/app_chrome.rs index 195c201..22fe90c 100644 --- a/src/app_chrome.rs +++ b/src/app_chrome.rs @@ -139,6 +139,7 @@ impl TetherApp { ui.separator(); 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); @@ -148,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); } diff --git a/src/app_palette.rs b/src/app_palette.rs index c05bdac..6358286 100644 --- a/src/app_palette.rs +++ b/src/app_palette.rs @@ -227,6 +227,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; diff --git a/src/app_views.rs b/src/app_views.rs index ca5d101..ae91744 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,19 @@ 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(), pick_folder: false })); + } + 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 +90,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() { @@ -160,13 +174,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 { 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/ui/activity.rs b/src/ui/activity.rs index adb5022..69cdc22 100644 --- a/src/ui/activity.rs +++ b/src/ui/activity.rs @@ -83,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); } @@ -106,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, } } @@ -139,25 +140,6 @@ impl Strip<'_> { self.item(ui, v, r, rect, events); items.push(r); } - if self.container == Container::Primary { - let r = if vertical { - Rect::from_min_size(Pos2::new(rect.center().x - ITEM / 2.0, cursor), Vec2::splat(ITEM)) - } else { - Rect::from_min_size(Pos2::new(cursor, rect.center().y - ROW_ITEM / 2.0), Vec2::splat(ROW_ITEM)) - }; - let kind = crate::agents::AgentKind::Codex; - let resp = ui.interact(r, Id::new("codex_launcher"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Launch Codex")); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(8), theme::surface_2()); - } - super::agent_icons::paint(ui.painter(), Rect::from_center_size(r.center(), Vec2::splat(GLYPH)), kind, 1.0); - if resp.clicked() { - events.push(ViewEvent::LaunchAgent(kind)); - } - resp.context_menu(|ui| agent_boot_menu(ui, kind, events)); - resp.on_hover_text("Launch Codex Β· Right-click for Custom Boot"); - } targets.strips.push(StripTarget { container: self.container, rect, vertical, items, views: self.state.views.clone() }); } @@ -239,8 +221,8 @@ 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 v == View::Agents { - agent_boot_menu(ui, crate::agents::AgentKind::Claude, events); + if let Some(kind) = v.agent() { + agent_boot_menu(ui, kind, events); ui.separator(); } ui.menu_button("Move to", |ui| { diff --git a/src/ui/agent_status.rs b/src/ui/agent_status.rs index dc0424d..bce0424 100644 --- a/src/ui/agent_status.rs +++ b/src/ui/agent_status.rs @@ -307,8 +307,9 @@ fn popover_body(ui: &mut egui::Ui, d: &PopoverData<'_>) -> Option } }); 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 b41d52a..a47872a 100644 --- a/src/ui/agents_panel.rs +++ b/src/ui/agents_panel.rs @@ -18,40 +18,84 @@ pub fn default_command(kind: AgentKind) -> &'static str { pub struct CustomBoot { pub kind: AgentKind, command: String, + prompt: String, focus: bool, } impl CustomBoot { pub fn new(kind: AgentKind) -> Self { - Self { kind, command: default_command(kind).into(), focus: true } + Self { kind, command: default_command(kind).into(), prompt: String::new(), focus: true } } pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { let mut command = None; let mut close = false; - let modal = egui::Modal::new(egui::Id::new("custom_agent_boot")).show(ctx, |ui| { - ui.set_width(440.0); - ui.horizontal(|ui| { - super::agent_icons::icon(ui, self.kind, 24.0); - ui.heading(format!("{} Β· Custom Boot", self.kind.name())); - }); - ui.label("Enter the command to run in a new terminal."); - ui.add_space(8.0); - let response = ui.add(egui::TextEdit::singleline(&mut self.command).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY)); - if std::mem::take(&mut self.focus) { - response.request_focus(); - } - let valid = !self.command.trim().is_empty() && !self.command.contains(['\r', '\n', '\0']); - ui.add_space(8.0); - ui.horizontal(|ui| { - let enter = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if ui.add_enabled(valid, egui::Button::new("Start terminal").fill(theme::accent_soft())).clicked() || (valid && enter) { - command = Some(self.command.trim().to_owned()); - close = true; + 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(500.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!("{} Β· Custom Boot", self.kind.name())).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", + "Runs with your installed CLI and its existing sign-in.", + |ui| { + let width = ui.available_width(); + form::framed(ui, false, |ui| { + ui.add(form::style_edit(egui::TextEdit::singleline(&mut self.command), width).font(egui::TextStyle::Monospace)) + }) + }, + ); + if std::mem::take(&mut self.focus) { + response.request_focus(); } - if ui.button("Cancel").clicked() { - close = true; + let needs_prompt = launch::needs_initial_prompt(&self.command); + if needs_prompt { + ui.add_space(12.0); + form::section( + ui, + Some(super::settings_widgets::Icon::Editor), + "Initial prompt", + "Print / exec mode needs a prompt and exits when finished.", + |ui| { + let width = ui.available_width(); + form::text(ui, &mut self.prompt, "What should the agent do?", width, false); + ui.add_space(6.0); + if form::button(ui, "Use interactive command", Kind::Ghost, true).clicked() { + self.command = default_command(self.kind).into(); + } + }, + ); + } else { + ui.add_space(10.0); + form::note(ui, "Right-click the agent icon to open Custom Boot again.", false); } + let valid = !self.command.trim().is_empty() + && !self.command.contains(['\r', '\n', '\0']) + && (!needs_prompt || !self.prompt.trim().is_empty() && !self.prompt.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) { + let powershell = crate::pty::shells::default_shell().is_some_and(|s| s.icon_kind == crate::pty::shells::ShellIcon::PowerShell); + command = Some(launch::append_initial_prompt(&self.command, if needs_prompt { self.prompt.trim() } else { "" }, powershell)); + close = true; + } + if form::button(ui, "Cancel", Kind::Secondary, true).clicked() { + close = true; + } + }); }); }); (command, close || modal.should_close()) @@ -127,12 +171,17 @@ 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)); @@ -143,66 +192,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)); - for kind in [AgentKind::Claude, AgentKind::Codex] { - let (rect, resp) = ui.allocate_exact_size(Vec2::new(width, 30.0), Sense::click()); - ui.painter().rect_filled(rect, CornerRadius::same(7), if resp.hovered() { theme::accent_soft() } else { theme::surface_3() }); - super::agent_icons::paint( - ui.painter(), - Rect::from_center_size(egui::pos2(rect.left() + 16.0, rect.center().y), Vec2::splat(16.0)), - kind, - 1.0, - ); - ui.painter().text( - egui::pos2(rect.left() + 32.0, rect.center().y), - Align2::LEFT_CENTER, - format!("Launch {}", kind.name()), - FontId::proportional(12.0), - theme::text(), - ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Launch {}", kind.name()))); - if resp.clicked() { - action = Some(PanelAction::Launch(LaunchPick { kind, command: default_command(kind).into(), pick_folder: false })); - } - resp.context_menu(|ui| { - if ui.button("Custom Boot…").clicked() { - action = Some(PanelAction::CustomBoot(kind)); - ui.close(); - } - }); - } - 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(), pick_folder: false })); + } + 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 } @@ -259,7 +275,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 } diff --git a/src/ui/attachments.rs b/src/ui/attachments.rs index a232d2c..2cc1a4c 100644 --- a/src/ui/attachments.rs +++ b/src/ui/attachments.rs @@ -18,15 +18,12 @@ pub struct RemoteTarget { struct Attachment { number: usize, texture: egui::TextureHandle, - name: String, - path: String, } struct Prepared { pixels: image::RgbaImage, local: PathBuf, path: String, - name: String, } #[derive(Default)] @@ -99,7 +96,7 @@ impl Attachments { } else { local.display().to_string() }; - Ok(Prepared { pixels, local, path: inserted, name: path.file_name().unwrap_or_default().to_string_lossy().into_owned() }) + Ok(Prepared { pixels, local, path: inserted }) }); let _ = tx.send(result); ctx.request_repaint(); @@ -146,12 +143,12 @@ impl Attachments { }; self.pending.remove(i); match result { - Ok(Prepared { pixels, local, path, name }) => { + 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, name, path: path.clone() }); + self.items.push(Attachment { number: self.next, texture }); paths.push(PathBuf::from(path)); } Err(e) => self.notice = Some(format!("Image attachment failed: {e:#}")), @@ -181,10 +178,8 @@ impl Attachments { 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(260.0); + ui.set_max_width(240.0); ui.image((item.texture.id(), item.texture.size_vec2())); - ui.label(&item.name); - ui.add(egui::Label::new(RichText::new(&item.path).small().color(theme::text_dim())).wrap()); }); } if !self.items.is_empty() @@ -260,7 +255,6 @@ mod tests { 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]); - assert_eq!(images.items[0].name, "reference.png"); drop(images); assert!(!ready[0].exists()); } diff --git a/src/ui/form.rs b/src/ui/form.rs index e530b62..977c15b 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); diff --git a/src/ui/import_view.rs b/src/ui/import_view.rs index 5eb8444..a81a7ed 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; @@ -106,46 +108,95 @@ impl ImportDialog { } } 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 / export machines"); - ui.horizontal(|ui| { - ui.selectable_value(&mut self.export, false, "Import"); - ui.selectable_value(&mut self.export, true, "Export setup"); + 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 self.export { - self.export_ui(ui, store); - if ui.button("Close").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:#}"), + }); } - return; - } - 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() { + 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); } }); + ui.add_space(10.0); ui.horizontal(|ui| { - if ui.button("Import Tether setup…").clicked() { - if 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 ui.add_enabled(self.scan.is_none(), egui::Button::new("Search this PC")).clicked() { + 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 = ctx.clone(); + let ctx = ui.ctx().clone(); std::thread::spawn(move || { let _ = tx.send(crate::import::discovery::scan(crate::import::discovery::roots())); ctx.request_repaint(); @@ -154,74 +205,86 @@ impl ImportDialog { 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.label(path.display().to_string()); + ui.add(egui::Label::new(RichText::new(path.display().to_string()).size(11.0).color(theme::text_dim())).wrap()); } }); } - 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 ui.button("Cancel").clicked() { - outcome = ImportOutcome::Cancel; - } - }); }); - if modal.should_close() && matches!(outcome, ImportOutcome::Open) { - outcome = ImportOutcome::Cancel; - } - outcome + 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) { - ui.add_space(8.0); - ui.label( - RichText::new("Anyone with this file can import your machine names, IP addresses / hostnames, ports, usernames and colors.").color(theme::warn()), - ); - ui.label("SSH keys, key paths, passwords and passphrases are excluded. Imported machines require credentials to connect."); - ui.add_space(8.0); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - for p in &store.profiles { - ui.label(format!("{} Β· {}", p.display_name(), p.address())); + 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.checkbox(&mut self.acknowledged, "I understand the file contains connection details"); - if ui.add_enabled(self.acknowledged && !store.profiles.is_empty(), egui::Button::new(format!("Export {} machines…", store.profiles.len()))).clicked() - { - 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:#}"), - }); - } - } - if let Some(n) = &self.notice { - ui.label(n); + 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.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:#}"), + }); } } 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| { @@ -232,32 +295,63 @@ impl ImportDialog { 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::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) { + 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.add_enabled(!row.exists, egui::Checkbox::new(&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.label(RichText::new(row.candidate.origin.display().to_string()).size(10.5).color(theme::text_faint())); - } + 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/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/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/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 index 2d4f401..4670baf 100644 --- a/tests/general_updates.rs +++ b/tests/general_updates.rs @@ -18,13 +18,31 @@ fn custom_boot_and_export_flow() { 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"); - for (launcher, title) in [("Agents view", "Claude Code Β· Custom Boot"), ("Launch Codex", "Codex Β· Custom Boot")] { + shot(&mut h, "general-codex-view"); + 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"); + h.get_by_label("Initial prompt"); + h.get_by_label("Start terminal").click(); + h.run_steps(5); + h.get_by_label(title); + shot(&mut h, "general-print-prompt"); + h.get_by_label("Use interactive command").click(); + h.run_steps(5); + assert!(h.query_by_label("Initial prompt").is_none()); + } h.get_by_role(egui::accesskit::Role::TextInput).focus(); h.key_press_modifiers(Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Key::A); h.event(egui::Event::Text("echo custom_boot_ok".into())); @@ -38,6 +56,7 @@ fn custom_boot_and_export_flow() { 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"); @@ -63,3 +82,10 @@ fn shot(h: &mut Harness<'_, TetherApp>, name: &str) { 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) { + 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(text.into())); + h.run_steps(5); +} From 9536b4bd81d591d68c9f07745a13ffb6ea6a5ce5 Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Wed, 21 Oct 2026 22:14:40 -0500 Subject: [PATCH 4/7] fix: respect local agent flags and prevent duplicate image pastes --- Cargo.lock | 134 ++++++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- README.md | 2 +- assets/agents/README.md | 1 + assets/agents/codex.svg | 1 + src/agents/launch.rs | 80 ----------------------- src/app_agents.rs | 14 ++-- src/app_palette.rs | 2 +- src/app_views.rs | 8 ++- src/settings.rs | 12 +++- src/theme.rs | 6 +- src/ui/agent_icons.rs | 28 +++++--- src/ui/agents_panel.rs | 94 +++++++++++++++++---------- src/ui/form.rs | 2 +- src/ui/import_view.rs | 14 +++- src/ui/keys.rs | 5 ++ src/ui/os_input.rs | 65 +++++++++++++++++++ src/ui/terminal_view.rs | 89 ++++++++++++++++++++++---- tests/general_updates.rs | 15 ++--- 19 files changed, 415 insertions(+), 160 deletions(-) create mode 100644 assets/agents/README.md create mode 100644 assets/agents/codex.svg diff --git a/Cargo.lock b/Cargo.lock index b0c5b7c..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" @@ -4968,7 +5068,6 @@ dependencies = [ "serde_json", "serde_norway", "sha2", - "shell-words", "ssh-key", "sysinfo", "tempfile", @@ -5079,6 +5178,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -5336,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" @@ -6458,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 1e2b2eb..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"] } @@ -34,7 +34,6 @@ vt100 = "0.16.2" vte = "0.15" arboard = "3.7" tempfile = "3" -shell-words = "1.1" zeroize = "1" portable-pty = "0.9" serde_norway = "0.9.42" diff --git a/README.md b/README.md index 4329074..0076e86 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B - **Shift+Enter** inserts a line without submitting in Claude Code, Codex and integrated PowerShell, Bash, Zsh and Fish prompts. Enter submits normally. - **Images**: use **+ Image**, paste a screenshot or drop PNG/JPEG files onto a terminal. Colored **[Image 1]** labels show a thumbnail on hover. Tether inserts a usable file path for the shell/agent; SSH terminals upload the image over SFTP first into a private directory under the remote user's home. Local temporary copies are removed when the terminal closes. Remote copies remain available to the agent. **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 to enter a command for a new default shell. Interactive commands such as `claude --dangerously-skip-permissions` keep their original arguments. Commands such as `claude -p` or `codex exec` without input show a required **Initial prompt** field, or you can choose **Use interactive command**. +- **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. ### Share machine setups and find SSH keys 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/agents/launch.rs b/src/agents/launch.rs index 3dfb813..d2a3f9f 100644 --- a/src/agents/launch.rs +++ b/src/agents/launch.rs @@ -6,71 +6,6 @@ use std::sync::OnceLock; use super::AgentKind; -/// Definite missing-input cases, without rewriting the command the user entered. -pub fn needs_initial_prompt(command: &str) -> bool { - let Ok(words) = shell_words::split(command) else { return false }; - let Some(program) = words.first() else { return false }; - let program = program.rsplit(['/', '\\']).next().unwrap_or(program).trim_end_matches(['.', ' ']).to_ascii_lowercase(); - let claude = ["claude", "claude.exe", "claude.cmd"].contains(&program.as_str()); - let codex = ["codex", "codex.exe", "codex.cmd"].contains(&program.as_str()); - if words.iter().any(|s| s == "--help" || s == "-h" || s == "--version") { - return false; - } - if !(claude && words.iter().any(|s| s == "-p" || s == "--print") || codex && words.get(1).is_some_and(|s| s == "exec")) { - return false; - } - let mut skip = false; - for word in words.iter().skip(if codex { 2 } else { 1 }) { - if skip { - skip = false; - continue; - } - if [ - "--model", - "-m", - "--output-format", - "--input-format", - "--permission-mode", - "--allowedTools", - "--disallowedTools", - "--add-dir", - "--system-prompt", - "--append-system-prompt", - "--max-turns", - "--max-budget-usd", - "--resume", - "-r", - "--cwd", - "-C", - "--config", - "-c", - "--profile", - "--sandbox", - "--output-last-message", - "-o", - ] - .contains(&word.as_str()) - { - skip = true; - continue; - } - if !word.starts_with('-') && !word.is_empty() { - return false; - } - } - true -} - -/// Literal shell argument for the optional initial prompt. Custom commands themselves -/// intentionally remain user-authored shell syntax. -pub fn append_initial_prompt(command: &str, prompt: &str, powershell: bool) -> String { - if prompt.is_empty() { - return command.trim().into(); - } - let quoted = if powershell { format!("'{}'", prompt.replace('\'', "''")) } else { shell_words::quote(prompt).into_owned() }; - format!("{} {quoted}", command.trim()) -} - #[derive(Debug, Clone)] pub struct Launcher { pub kind: AgentKind, @@ -126,21 +61,6 @@ fn is_file(p: &Path) -> bool { mod tests { use super::*; - #[test] - fn print_modes_require_real_input_but_interactive_commands_do_not() { - for command in ["claude -p", "claude --print --model sonnet", "claude -p --dangerously-skip-permissions", "codex exec"] { - assert!(needs_initial_prompt(command), "{command}"); - } - for command in - ["claude", "claude --dangerously-skip-permissions", "claude -p 'hello'", "claude -p --model sonnet 'hello'", "codex", "codex exec --help"] - { - assert!(!needs_initial_prompt(command), "{command}"); - } - assert_eq!(append_initial_prompt("claude -p", "Don't run $(anything)", true), "claude -p 'Don''t run $(anything)'"); - let command = append_initial_prompt("claude -p", "hello 'quoted' world", false); - assert_eq!(shell_words::split(&command).unwrap().last().unwrap(), "hello 'quoted' world"); - } - #[test] fn finds_commands_with_extensions_in_path_order() { let a = tempfile::tempdir().unwrap(); diff --git a/src/app_agents.rs b/src/app_agents.rs index 84e7490..17f7cfc 100644 --- a/src/app_agents.rs +++ b/src/app_agents.rs @@ -218,25 +218,26 @@ 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, pick_folder: false })), ctx); + self.apply_agents_panel(Some(PanelAction::Launch(LaunchPick { kind, command, pick_folder: false, shell_id: None })), ctx); } pub(super) fn custom_boot_modal(&mut self, ctx: &egui::Context) { let Some(boot) = &mut self.custom_boot else { return }; - let kind = boot.kind; let (command, close) = boot.show(ctx); if close { self.custom_boot = None; } if let Some(command) = command { - self.launch_agent_command(kind, command, ctx); + 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 { + 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; }; @@ -248,6 +249,11 @@ impl TetherApp { } else { None }; + 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_palette.rs b/src/app_palette.rs index 6358286..29632b1 100644 --- a/src/app_palette.rs +++ b/src/app_palette.rs @@ -155,7 +155,7 @@ impl TetherApp { 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: command.into(), pick_folder }; + let pick = crate::ui::agents_panel::LaunchPick { kind, command: command.into(), pick_folder, shell_id: None }; let target = self .dock .focused_leaf() diff --git a/src/app_views.rs b/src/app_views.rs index ae91744..76136f9 100644 --- a/src/app_views.rs +++ b/src/app_views.rs @@ -61,8 +61,12 @@ impl Host<'_, '_> { 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(), pick_folder: false })); + self.agent_action = Some(PanelAction::Launch(agents_panel::LaunchPick { + kind, + command: agents_panel::default_command(kind).into(), + pick_folder: false, + shell_id: None, + })); } if crate::ui::settings_widgets::icon_button(ui, crate::ui::settings_widgets::Icon::Gear, 24.0) .on_hover_text("Agent settings and hooks") diff --git a/src/settings.rs b/src/settings.rs index a4b863e..0136442 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(), + } } } 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/agent_icons.rs b/src/ui/agent_icons.rs index c09d9d7..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's terminal mark 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}; @@ -52,12 +52,24 @@ fn starburst(p: &Painter, center: Pos2, radius: f32, c: Color32) { p.circle_filled(center, radius * 0.2, c); } -/// Codex's familiar >_ terminal mark, readable even in the activity strip. +/// 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 stroke = Stroke::new((radius * 0.2).clamp(1.2, 2.0), c); - let at = |x, y| center + Vec2::new(x * radius, y * radius); - p.add(Shape::line(vec![at(-0.85, -0.6), at(-0.15, 0.0), at(-0.85, 0.6)], stroke)); - p.line_segment([at(0.15, 0.6), at(0.9, 0.6)], stroke); + 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/agents_panel.rs b/src/ui/agents_panel.rs index a47872a..c9a065b 100644 --- a/src/ui/agents_panel.rs +++ b/src/ui/agents_panel.rs @@ -18,16 +18,18 @@ pub fn default_command(kind: AgentKind) -> &'static str { pub struct CustomBoot { pub kind: AgentKind, command: String, - prompt: String, + shell_id: String, focus: bool, } impl CustomBoot { pub fn new(kind: AgentKind) -> Self { - Self { kind, command: default_command(kind).into(), prompt: String::new(), focus: true } + 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 } } - pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { + pub fn show(&mut self, ctx: &egui::Context) -> (Option, bool) { let mut command = None; let mut close = false; use super::form::{self, Kind}; @@ -47,7 +49,7 @@ impl CustomBoot { ui, Some(super::settings_widgets::Icon::Terminal), "Launch command", - "Runs with your installed CLI and its existing sign-in.", + "Run your local command with its own flags and account setup.", |ui| { let width = ui.available_width(); form::framed(ui, false, |ui| { @@ -58,38 +60,34 @@ impl CustomBoot { if std::mem::take(&mut self.focus) { response.request_focus(); } - let needs_prompt = launch::needs_initial_prompt(&self.command); - if needs_prompt { - ui.add_space(12.0); - form::section( - ui, - Some(super::settings_widgets::Icon::Editor), - "Initial prompt", - "Print / exec mode needs a prompt and exits when finished.", - |ui| { - let width = ui.available_width(); - form::text(ui, &mut self.prompt, "What should the agent do?", width, false); - ui.add_space(6.0); - if form::button(ui, "Use interactive command", Kind::Ghost, true).clicked() { - self.command = default_command(self.kind).into(); - } - }, - ); - } else { - ui.add_space(10.0); - form::note(ui, "Right-click the agent icon to open Custom Boot again.", false); - } - let valid = !self.command.trim().is_empty() - && !self.command.contains(['\r', '\n', '\0']) - && (!needs_prompt || !self.prompt.trim().is_empty() && !self.prompt.contains(['\r', '\n', '\0'])); + 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) { - let powershell = crate::pty::shells::default_shell().is_some_and(|s| s.icon_kind == crate::pty::shells::ShellIcon::PowerShell); - command = Some(launch::append_initial_prompt(&self.command, if needs_prompt { self.prompt.trim() } else { "" }, powershell)); + command = Some(LaunchPick { + kind: self.kind, + command: self.command.trim().to_owned(), + pick_folder: false, + shell_id: Some(self.shell_id.clone()), + }); close = true; } if form::button(ui, "Cancel", Kind::Secondary, true).clicked() { @@ -120,6 +118,8 @@ pub struct LaunchPick { pub command: String, /// Ask for the folder to start in first. pub pick_folder: bool, + /// A chosen local shell, including its normal startup profiles. + pub shell_id: Option, } /// "Claude Code" / "Codex" entries for a "+" menu: only agents found on PATH. @@ -134,10 +134,10 @@ pub fn launch_menu(ui: &mut egui::Ui) -> Option { 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.into(), pick_folder: false }); + pick = Some(LaunchPick { kind: l.kind, command: l.command.into(), pick_folder: false, shell_id: None }); } 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.into(), pick_folder: true }); + pick = Some(LaunchPick { kind: l.kind, command: l.command.into(), pick_folder: true, shell_id: None }); } }); } @@ -209,7 +209,7 @@ fn empty_state(ui: &mut egui::Ui, kind: AgentKind) -> Option { 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(), pick_folder: false })); + action = Some(PanelAction::Launch(LaunchPick { kind, command: default_command(kind).into(), pick_folder: false, shell_id: None })); } ui.add_space(4.0); if form::button(ui, "Custom Boot…", Kind::Secondary, true).clicked() { @@ -290,6 +290,34 @@ 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 }; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.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/form.rs b/src/ui/form.rs index 977c15b..978834b 100644 --- a/src/ui/form.rs +++ b/src/ui/form.rs @@ -313,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 a81a7ed..4843084 100644 --- a/src/ui/import_view.rs +++ b/src/ui/import_view.rs @@ -261,7 +261,19 @@ and passphrases", .color(theme::text_dim()), ); ui.add_space(8.0); - ui.checkbox(&mut self.acknowledged, "I understand the file contains connection details"); + 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"); + }); }); } diff --git a/src/ui/keys.rs b/src/ui/keys.rs index 4812b41..0a99a97 100644 --- a/src/ui/keys.rs +++ b/src/ui/keys.rs @@ -101,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() } } @@ -145,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/os_input.rs b/src/ui/os_input.rs index 06056f4..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 { @@ -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/terminal_view.rs b/src/ui/terminal_view.rs index e13c083..f72c118 100644 --- a/src/ui/terminal_view.rs +++ b/src/ui/terminal_view.rs @@ -70,8 +70,6 @@ pub struct TerminalView { 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. @@ -80,6 +78,9 @@ 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, @@ -139,9 +140,10 @@ impl TerminalView { prompt_col: None, 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(), @@ -604,7 +606,9 @@ impl TerminalView { out.input.extend(keys::encode_text(&t, mods.alt && !mods.ctrl)); } Event::Paste(t) => { - self.ctrl_v_handled = true; + if !self.cache.paste_guard.explicit(ui.ctx().cumulative_frame_nr()) { + continue; + } let files = super::os_input::clipboard_files(); if files.iter().any(|p| super::attachments::is_image(p)) { let (images, other): (Vec<_>, Vec<_>) = files.into_iter().partition(|p| super::attachments::is_image(p)); @@ -698,16 +702,9 @@ 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; + 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); } - if !down && !had_paste { - self.ctrl_v_handled = false; - } } fn paste_clipboard_non_text(&mut self, ctx: &egui::Context, bracketed: bool, out: &mut ViewOutput) { @@ -755,6 +752,11 @@ impl TerminalView { 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(); let (images, files): (Vec<_>, Vec<_>) = dropped.into_iter().partition(|p| super::attachments::is_image(p)); self.cache.attachments.add(images, ui.ctx()); @@ -1111,6 +1113,69 @@ mod bench; mod tests { use super::*; + #[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); + 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/tests/general_updates.rs b/tests/general_updates.rs index 4670baf..7eec5cf 100644 --- a/tests/general_updates.rs +++ b/tests/general_updates.rs @@ -11,7 +11,8 @@ fn custom_boot_and_export_flow() { unsafe { std::env::set_var("TETHER_CONFIG_DIR", config.path()); } - std::fs::write(config.path().join("settings.json"), r#"{"updates":{"check_on_startup":false}}"#).unwrap(); + 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(); @@ -34,14 +35,9 @@ fn custom_boot_and_export_flow() { h.get_by_label(title); if launcher == "Agents view" { replace_command(&mut h, "claude -p"); - h.get_by_label("Initial prompt"); - h.get_by_label("Start terminal").click(); - h.run_steps(5); - h.get_by_label(title); - shot(&mut h, "general-print-prompt"); - h.get_by_label("Use interactive command").click(); - h.run_steps(5); assert!(h.query_by_label("Initial prompt").is_none()); + h.get_by_label("Terminal shell"); + shot(&mut h, "general-account-flag"); } h.get_by_role(egui::accesskit::Role::TextInput).focus(); h.key_press_modifiers(Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Key::A); @@ -62,6 +58,9 @@ fn custom_boot_and_export_flow() { 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(); From 571520ab658583b9998ff4d4ea905d4343e863a8 Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Thu, 22 Oct 2026 00:46:13 -0500 Subject: [PATCH 5/7] Add project launch cards, terminal color capabilities and SSH path browsing --- README.md | 4 + src/actions.rs | 3 +- src/app.rs | 36 ++++++ src/app_agents.rs | 45 +++++-- src/app_palette.rs | 9 +- src/app_views.rs | 2 +- src/lib.rs | 2 + src/local_projects.rs | 93 +++++++++++++++ src/pty/local.rs | 6 +- src/settings.rs | 1 + src/ssh/terminal.rs | 9 +- src/terminal_capabilities.rs | 13 +++ src/ui/agents_panel.rs | 79 +++++++++---- src/ui/mod.rs | 1 + src/ui/path_field.rs | 5 +- src/ui/project_picker.rs | 219 +++++++++++++++++++++++++++++++++++ src/ui/sftp_session.rs | 80 ++++++++++++- src/ui/sftp_view.rs | 20 ++++ src/ui/tabs.rs | 31 ++++- src/ui/term_links.rs | 34 ++++++ src/ui/terminal_view.rs | 29 ++++- tests/general_updates.rs | 26 ++++- tests/local_pty.rs | 35 ++++++ 23 files changed, 727 insertions(+), 55 deletions(-) create mode 100644 src/local_projects.rs create mode 100644 src/terminal_capabilities.rs create mode 100644 src/ui/project_picker.rs diff --git a/README.md b/README.md index 0076e86..7eeea54 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B - **Images**: use **+ Image**, paste a screenshot or drop PNG/JPEG files onto a terminal. Colored **[Image 1]** labels show a thumbnail on hover. Tether inserts a usable file path for the shell/agent; SSH terminals upload the image over SFTP first into a private directory under the remote user's home. Local temporary copies are removed when the terminal closes. Remote copies remain available to the agent. **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**: right-click a path printed in an SSH terminal and choose **Open in SFTP**. Tether opens or reuses that terminal's linked SFTP pane, resolves relative paths against the remote shell's directory, and opens a file's containing folder when appropriate. +- **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 diff --git a/src/actions.rs b/src/actions.rs index 6add539..0be03c2 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -39,11 +39,10 @@ pub enum Command { OpenLayoutSettings, /// The "Import hosts" dialog (Bitvise, SSH config). ImportHosts, - /// Start an AI agent CLI in a new default shell. + /// Choose a local project and shell, then start an AI agent CLI. LaunchAgent { kind: crate::agents::AgentKind, command: &'static str, - pick_folder: bool, }, /// Anything else: runs on the UI thread when chosen. Run(Arc), diff --git a/src/app.rs b/src/app.rs index 85c26ba..48f707e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -88,6 +88,7 @@ pub struct TetherApp { editor: Option, import: Option, custom_boot: Option, + project_cwds: std::collections::HashMap, settings_view: Option, key_manager: Option, prompt: Option, @@ -178,6 +179,7 @@ impl TetherApp { mini_dock: Default::default(), agents, custom_boot: None, + project_cwds: Default::default(), }) } @@ -428,6 +430,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); } @@ -609,6 +614,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()); } @@ -698,6 +706,34 @@ impl TetherApp { } } + /// Reuse or open the source terminal's SFTP pane, then resolve the clicked path remotely. + fn open_terminal_folder(&mut self, uid: u64, path: String, ctx: &egui::Context) { + let Some((profile, terminal)) = 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())), + _ => None, + }) else { + return; + }; + let already_open = self.all_tabs().any(|t| t.uid == uid && t.linked_sftp.is_some()); + 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)) + }; + 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(view) = new_view { + tab.linked_sftp = Some(crate::ui::tabs::LinkedSftp::new(view)); + } + if let Some(linked) = &mut tab.linked_sftp { + linked.view.set_linked_terminal(Some(terminal)); + linked.view.browse_path(path); + } + } + self.focus_tab(uid, ctx); + } + /// 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. diff --git a/src/app_agents.rs b/src/app_agents.rs index 17f7cfc..ec66286 100644 --- a/src/app_agents.rs +++ b/src/app_agents.rs @@ -78,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 { @@ -218,37 +236,46 @@ 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, pick_folder: false, shell_id: None })), ctx); + 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 { - self.apply_agents_panel(Some(PanelAction::Launch(command)), ctx); + 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 { + 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()); diff --git a/src/app_palette.rs b/src/app_palette.rs index 29632b1..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,8 +155,8 @@ 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: command.into(), pick_folder, shell_id: None }; + 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() diff --git a/src/app_views.rs b/src/app_views.rs index 76136f9..7832474 100644 --- a/src/app_views.rs +++ b/src/app_views.rs @@ -64,8 +64,8 @@ impl Host<'_, '_> { self.agent_action = Some(PanelAction::Launch(agents_panel::LaunchPick { kind, command: agents_panel::default_command(kind).into(), - pick_folder: false, shell_id: None, + cwd: None, })); } if crate::ui::settings_widgets::icon_button(ui, crate::ui::settings_widgets::Icon::Gear, 24.0) diff --git a/src/lib.rs b/src/lib.rs index 564c895..130f517 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ 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; @@ -22,6 +23,7 @@ pub mod settings; 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/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/settings.rs b/src/settings.rs index 0136442..b2013fc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -267,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/ssh/terminal.rs b/src/ssh/terminal.rs index 21ff65a..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; @@ -336,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?; 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/ui/agents_panel.rs b/src/ui/agents_panel.rs index c9a065b..6a6caa6 100644 --- a/src/ui/agents_panel.rs +++ b/src/ui/agents_panel.rs @@ -20,13 +20,35 @@ pub struct CustomBoot { 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 } + 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) { @@ -35,12 +57,12 @@ impl CustomBoot { 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(500.0); + 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!("{} Β· Custom Boot", self.kind.name())).size(19.0).strong()); + 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())); }); }); @@ -53,14 +75,20 @@ impl CustomBoot { |ui| { let width = ui.available_width(); form::framed(ui, false, |ui| { - ui.add(form::style_edit(egui::TextEdit::singleline(&mut self.command), width).font(egui::TextStyle::Monospace)) + 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) { - response.request_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()); @@ -82,13 +110,15 @@ impl CustomBoot { 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) { - command = Some(LaunchPick { - kind: self.kind, - command: self.command.trim().to_owned(), - pick_folder: false, - shell_id: Some(self.shell_id.clone()), - }); - close = true; + 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; @@ -116,10 +146,10 @@ pub enum PanelAction { pub struct LaunchPick { pub kind: AgentKind, pub command: String, - /// Ask for the folder to start in first. - pub pick_folder: bool, /// 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. @@ -133,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.into(), pick_folder: false, shell_id: None }); - } - 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.into(), pick_folder: true, shell_id: None }); + 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 }); } }); } @@ -209,7 +236,7 @@ fn empty_state(ui: &mut egui::Ui, kind: AgentKind) -> Option { 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(), pick_folder: false, shell_id: None })); + 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() { @@ -299,8 +326,16 @@ mod custom_boot_tests { 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 }; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui(move |ui| { + 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 { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 188cd75..75e966d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -28,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/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/sftp_session.rs b/src/ui/sftp_session.rs index 8520ca1..e328c81 100644 --- a/src/ui/sftp_session.rs +++ b/src/ui/sftp_session.rs @@ -309,11 +309,61 @@ 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) { + let (Some(sftp), Some(session)) = (self.sftp(), self.session()) else { return }; + let (term, profile, jump, jumping, ctx) = + (self.linked_terminal.clone(), 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 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 conn = session.pool.get(&profile, secret::get(profile.secret_id())).await?; + 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 = location_path(&path, &cwd, &home); + 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 +379,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); @@ -339,6 +396,19 @@ impl SftpView { } } +fn location_path(path: &str, cwd: &str, home: &str) -> String { + let path = if path == "~" { + home.into() + } else if let Some(rest) = path.strip_prefix("~/") { + crate::ssh::sftp::join(home, rest) + } else if path.starts_with('/') { + path.into() + } else { + crate::ssh::sftp::join(cwd, path) + }; + super::path_complete::normalize_remote(&path) +} + /// The delay before automatic reconnect attempt `attempt` (1-based); `None` once they /// are used up. pub(super) fn retry_delay(attempt: u32) -> Option { @@ -395,6 +465,14 @@ pub(super) fn humanize(err: &anyhow::Error) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn terminal_locations_resolve_on_the_remote_host() { + assert_eq!(location_path("../other project", "/srv/project", "/home/dev"), "/srv/other project"); + assert_eq!(location_path("~/src", "/srv/project", "/home/dev"), "/home/dev/src"); + assert_eq!(location_path("~", "/srv/project", "/home/dev"), "/home/dev"); + assert_eq!(location_path("/etc/nginx", "/srv/project", "/home/dev"), "/etc/nginx"); + } 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 fa4fa2d..0c00435 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, + location_epoch: Arc, jumping: Arc, /// Files opened with the PC's default app, uploaded on every save. watcher: Watcher, @@ -205,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(), @@ -266,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.show_remote = true; + self.ctx.request_repaint(); + } + fn sftp(&self) -> Option> { match &self.remote.lock().ok()?.link { Link::Ready { sftp, .. } => Some(sftp.clone()), @@ -648,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) = self.pending_location.take() + { + self.resolve_location(path); + } 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/tabs.rs b/src/ui/tabs.rs index 8f02060..6416441 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -278,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)>, @@ -320,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 { @@ -531,6 +539,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)); } @@ -588,6 +599,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)); } @@ -634,10 +648,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(); } @@ -834,6 +849,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_links.rs b/src/ui/term_links.rs index 64f435b..bb6c3e1 100644 --- a/src/ui/term_links.rs +++ b/src/ui/term_links.rs @@ -66,6 +66,8 @@ 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)`. @@ -240,6 +242,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; @@ -294,6 +300,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)); @@ -728,6 +735,33 @@ fn clean_path(path: &Path) -> PathBuf { mod tests { use super::*; + #[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() }; + let (intercepted, action) = links.on_click(egui::pos2(30.0, 12.0), false, &egui::Context::default()); + assert!(intercepted); + assert!(action.is_none()); + 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_view.rs b/src/ui/terminal_view.rs index f72c118..5888412 100644 --- a/src/ui/terminal_view.rs +++ b/src/ui/terminal_view.rs @@ -116,6 +116,17 @@ 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 { @@ -254,9 +265,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); @@ -536,6 +545,16 @@ impl TerminalView { (alt_screen, app_cursor): (bool, bool), out: &mut ViewOutput, ) { + if !alt_screen + && response.secondary_clicked() + && let Some(pos) = response.interact_pointer_pos() + { + let (intercepted, action) = self.cache.links.on_click(pos, false, ui.ctx()); + out.link_action(action); + if intercepted { + return; + } + } if response.drag_started() { if let Some(p) = response.interact_pointer_pos() { self.selection = Some((to_cell(p), to_cell(p))); @@ -550,9 +569,7 @@ impl TerminalView { 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; } } diff --git a/tests/general_updates.rs b/tests/general_updates.rs index 7eec5cf..8ede3d4 100644 --- a/tests/general_updates.rs +++ b/tests/general_updates.rs @@ -7,6 +7,8 @@ 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()); @@ -25,6 +27,13 @@ fn custom_boot_and_export_flow() { 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")] { @@ -35,11 +44,20 @@ fn custom_boot_and_export_flow() { 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_role(egui::accesskit::Role::TextInput).focus(); + 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); @@ -83,7 +101,11 @@ fn shot(h: &mut Harness<'_, TetherApp>, name: &str) { } fn replace_command(h: &mut Harness<'_, TetherApp>, text: &str) { - h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().focus(); + 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 89793e8..785647d 100644 --- a/tests/local_pty.rs +++ b/tests/local_pty.rs @@ -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)); +} From 55a8599acfcebdc3dd618a3cd41c74a9ba4e19ae Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Thu, 22 Oct 2026 01:50:50 -0500 Subject: [PATCH 6/7] Restrict image tools to agents and open SSH prompt paths in SFTP windows --- README.md | 4 +- src/app.rs | 40 +++++++++------- src/ui/sftp_session.rs | 5 +- src/ui/sftp_view.rs | 8 ++-- src/ui/tabs.rs | 2 + src/ui/term_links.rs | 78 +++++++++++++++++++++++++------ src/ui/terminal_view.rs | 101 ++++++++++++++++++++++++++++++++++------ 7 files changed, 184 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 7eeea54..b2f2288 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B ### 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**: use **+ Image**, paste a screenshot or drop PNG/JPEG files onto a terminal. Colored **[Image 1]** labels show a thumbnail on hover. Tether inserts a usable file path for the shell/agent; SSH terminals upload the image over SFTP first into a private directory under the remote user's home. Local temporary copies are removed when the terminal closes. Remote copies remain available to the agent. **Clear previews** only hides the thumbnails; edit the prompt in the terminal to remove an inserted path. +- **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**: right-click a path printed in an SSH terminal and choose **Open in SFTP**. Tether opens or reuses that terminal's linked SFTP pane, resolves relative paths against the remote shell's directory, and opens a file's containing folder when appropriate. +- **Remote path links**: click a remote path to open a dedicated SFTP window there; right-click offers **Open in SFTP**, the editor and copy options. Paths inside prompts such as `user@host:~/project$` are recognized without the username, hostname or prompt marker. Repeated clicks reuse and focus the source terminal's SFTP window. Relative paths resolve against that SSH 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 diff --git a/src/app.rs b/src/app.rs index 48f707e..d94c5f2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -89,6 +89,7 @@ pub struct TetherApp { import: Option, custom_boot: Option, project_cwds: std::collections::HashMap, + terminal_sftp_windows: std::collections::HashMap, settings_view: Option, key_manager: Option, prompt: Option, @@ -180,6 +181,7 @@ impl TetherApp { agents, custom_boot: None, project_cwds: Default::default(), + terminal_sftp_windows: Default::default(), }) } @@ -706,7 +708,7 @@ impl TetherApp { } } - /// Reuse or open the source terminal's SFTP pane, then resolve the clicked path remotely. + /// Open a dedicated SFTP window at a terminal link's location. fn open_terminal_folder(&mut self, uid: u64, path: String, ctx: &egui::Context) { let Some((profile, terminal)) = 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())), @@ -714,24 +716,28 @@ impl TetherApp { }) else { return; }; - let already_open = self.all_tabs().any(|t| t.uid == uid && t.linked_sftp.is_some()); - 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)) - }; - 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(view) = new_view { - tab.linked_sftp = Some(crate::ui::tabs::LinkedSftp::new(view)); - } - if let Some(linked) = &mut tab.linked_sftp { - linked.view.set_linked_terminal(Some(terminal)); - linked.view.browse_path(path); + let live: HashSet<_> = self.all_tabs().map(|t| t.uid).collect(); + self.terminal_sftp_windows.retain(|_, sftp_uid| live.contains(sftp_uid)); + if let Some(sftp_uid) = self.terminal_sftp_windows.get(&uid).copied() { + if let Some(Tab { body: TabBody::Sftp(view), .. }) = + self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == sftp_uid) + { + view.set_linked_terminal(Some(terminal)); + view.browse_path(path); } + self.apply_tab_actions(vec![crate::ui::vertical_tabs::TabAction::Focus(sftp_uid)], ctx); + return; } - self.focus_tab(uid, ctx); + let profile = self.store.get(profile.id).cloned().unwrap_or(profile); + let mut view = self.spawn_sftp(&profile, secret::get(profile.secret_id()), ctx); + view.set_linked_terminal(Some(terminal)); + view.browse_path(path); + let tab = Tab::new(profile, TabBody::Sftp(Box::new(view))); + let sftp_uid = tab.uid; + let screen_pos = ctx.input(|i| i.viewport().outer_rect.map(|r| r.min + egui::vec2(70.0, 70.0))).unwrap_or(egui::pos2(120.0, 120.0)); + self.detached.pop_out(tab, screen_pos); + self.terminal_sftp_windows.insert(uid, sftp_uid); + ctx.request_repaint(); } /// Opens files requested from a terminal (`nano `) in the built-in editor. diff --git a/src/ui/sftp_session.rs b/src/ui/sftp_session.rs index e328c81..860f5c8 100644 --- a/src/ui/sftp_session.rs +++ b/src/ui/sftp_session.rs @@ -310,10 +310,9 @@ impl SftpView { } /// Resolve a terminal link using the remote cwd and metadata, never the PC's filesystem. - pub(super) fn resolve_location(&self, path: String) { + 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) = - (self.linked_terminal.clone(), self.profile.clone(), self.jump.clone(), self.jumping.clone(), self.ctx.clone()); + 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); diff --git a/src/ui/sftp_view.rs b/src/ui/sftp_view.rs index 0c00435..4d590cb 100644 --- a/src/ui/sftp_view.rs +++ b/src/ui/sftp_view.rs @@ -143,7 +143,7 @@ pub struct SftpView { /// 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, + pending_location: Option<(String, Option)>, location_epoch: Arc, jumping: Arc, /// Files opened with the PC's default app, uploaded on every save. @@ -276,7 +276,7 @@ impl SftpView { if let Ok(mut jump) = self.jump.lock() { *jump = None; } - self.pending_location = Some(path); + self.pending_location = Some((path, self.linked_terminal.clone())); self.show_remote = true; self.ctx.request_repaint(); } @@ -664,9 +664,9 @@ 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) = self.pending_location.take() + && let Some((path, terminal)) = self.pending_location.take() { - self.resolve_location(path); + self.resolve_location(path, terminal); } self.poll_open(); self.poll_local(); diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 6416441..8a45406 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -498,10 +498,12 @@ 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())) { view.fallback_cwd = Some(cwd); diff --git a/src/ui/term_links.rs b/src/ui/term_links.rs index bb6c3e1..854265b 100644 --- a/src/ui/term_links.rs +++ b/src/ui/term_links.rs @@ -149,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; @@ -163,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 { @@ -179,7 +188,7 @@ 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())); @@ -488,9 +497,31 @@ 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) { + return classify_candidate(&text[path_start..path_end], path_start, cwd, false, cache); + } + 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; @@ -520,7 +551,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; @@ -541,7 +572,7 @@ 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; } @@ -651,16 +682,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 == "~" || 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 @@ -735,6 +761,32 @@ 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) in [ + ("prisma@na-prisma-02:~/servers/practice-servers/na-practice-01$", "~/servers/practice-servers/na-practice-01"), + ("root@server:/srv/app#", "/srv/app"), + ("[dev@host:~/repo]$", "~/repo"), + ("dev@host:~$", "~"), + ("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: expected.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(expected.into())))); + assert!(!links.has_open_menu()); + } + for folder in ["practice-01/", "servers/practice-01", "~/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; @@ -744,9 +796,7 @@ mod tests { spans: vec![RowSpan { row: 0, start_col: 0, end_col: 12 }], }; let mut links = LinksState { hovered: Some(link), ..Default::default() }; - let (intercepted, action) = links.on_click(egui::pos2(30.0, 12.0), false, &egui::Context::default()); - assert!(intercepted); - assert!(action.is_none()); + 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| { diff --git a/src/ui/terminal_view.rs b/src/ui/terminal_view.rs index 5888412..b71fda0 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, @@ -136,6 +138,7 @@ impl TerminalView { pub fn new(handle: TerminalHandle) -> Self { Self { + images_enabled: false, handle, size: (0, 0), scroll: 0, @@ -205,7 +208,8 @@ impl TerminalView { let settings = settings::snapshot(); let cfg = &settings.terminal; let full_rect = ui.available_rect_before_wrap(); - let bar_rect = Rect::from_min_max(egui::pos2(full_rect.left(), (full_rect.bottom() - 34.0).max(full_rect.top())), full_rect.max); + 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()); @@ -248,7 +252,7 @@ impl TerminalView { 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 !ready.is_empty() { + 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; @@ -315,7 +319,9 @@ impl TerminalView { } self.connecting_screen(ui, rect, &state, stage, profile, now); out.reconnect = state_overlay(ui, rect, &state); - self.cache.attachments.bar(ui, bar_rect); + if self.images_enabled { + self.cache.attachments.bar(ui, bar_rect); + } if !out.input.is_empty() { self.scroll = 0; self.selection = None; @@ -548,12 +554,9 @@ impl TerminalView { if !alt_screen && response.secondary_clicked() && let Some(pos) = response.interact_pointer_pos() + && self.cache.links.on_secondary_click(pos) { - let (intercepted, action) = self.cache.links.on_click(pos, false, ui.ctx()); - out.link_action(action); - if intercepted { - return; - } + return; } if response.drag_started() { if let Some(p) = response.interact_pointer_pos() { @@ -627,7 +630,7 @@ impl TerminalView { continue; } let files = super::os_input::clipboard_files(); - if files.iter().any(|p| super::attachments::is_image(p)) { + 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() { @@ -635,7 +638,7 @@ impl TerminalView { } continue; } - if super::os_input::clipboard_has_image() && self.cache.attachments.paste_image(ui.ctx()) { + if self.images_enabled && super::os_input::clipboard_has_image() && self.cache.attachments.paste_image(ui.ctx()) { continue; } self.mark_prompt_start(); @@ -660,7 +663,7 @@ impl TerminalView { }, Event::Cut => out.input.push(0x18), Event::Key { key, pressed: true, modifiers, .. } => { - if key == egui::Key::Enter && !modifiers.shift && self.cache.attachments.is_pending() { + 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; } @@ -727,7 +730,7 @@ impl TerminalView { 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() { - let (images, files): (Vec<_>, Vec<_>) = files.into_iter().partition(|p| super::attachments::is_image(p)); + 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); } @@ -737,7 +740,7 @@ impl TerminalView { } return; } - if super::os_input::clipboard_has_image() && !super::os_input::clipboard_has_text() { + if self.images_enabled && super::os_input::clipboard_has_image() && !super::os_input::clipboard_has_text() { self.cache.attachments.paste_image(ctx); } } @@ -775,7 +778,7 @@ impl TerminalView { } self.cache.last_drop_frame = Some(frame); self.mark_prompt_start(); - let (images, files): (Vec<_>, Vec<_>) = dropped.into_iter().partition(|p| super::attachments::is_image(p)); + 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 @@ -1130,6 +1133,75 @@ 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(), &["~/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; @@ -1153,6 +1225,7 @@ mod tests { 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 { From 3fe4e6de38d62bca8ca24834fa9920aef02b1f1d Mon Sep 17 00:00:00 2001 From: "6k2 (Juanes)" <6k2.oficial@gmail.com> Date: Thu, 22 Oct 2026 02:23:20 -0500 Subject: [PATCH 7/7] Open SSH path links in companion SFTP pane and resolve switched-user homes --- .github/workflows/ci.yml | 1 + README.md | 2 +- src/app.rs | 72 +++++++---------- src/ssh/cwd.rs | 64 +++++++++++++++ src/ui/sftp_session.rs | 24 +----- src/ui/term_links.rs | 40 +++++++--- src/ui/terminal_view.rs | 2 +- tests/sftp_prompt.rs | 167 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 294 insertions(+), 78 deletions(-) create mode 100644 tests/sftp_prompt.rs 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/README.md b/README.md index b2f2288..b3a2b4c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A personal project: the split-pane workspace of Termius plus the SFTP speed of B - **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 a dedicated SFTP window there; right-click offers **Open in SFTP**, the editor and copy options. Paths inside prompts such as `user@host:~/project$` are recognized without the username, hostname or prompt marker. Repeated clicks reuse and focus the source terminal's SFTP window. Relative paths resolve against that SSH terminal's directory; files open their containing folder. +- **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 diff --git a/src/app.rs b/src/app.rs index d94c5f2..b74bfc0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -89,7 +89,6 @@ pub struct TetherApp { import: Option, custom_boot: Option, project_cwds: std::collections::HashMap, - terminal_sftp_windows: std::collections::HashMap, settings_view: Option, key_manager: Option, prompt: Option, @@ -181,7 +180,6 @@ impl TetherApp { agents, custom_boot: None, project_cwds: Default::default(), - terminal_sftp_windows: Default::default(), }) } @@ -311,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); @@ -708,36 +702,32 @@ impl TetherApp { } } - /// Open a dedicated SFTP window at a terminal link's location. - fn open_terminal_folder(&mut self, uid: u64, path: String, ctx: &egui::Context) { - let Some((profile, terminal)) = 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())), + /// 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, - }) else { - return; + })?; + 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 live: HashSet<_> = self.all_tabs().map(|t| t.uid).collect(); - self.terminal_sftp_windows.retain(|_, sftp_uid| live.contains(sftp_uid)); - if let Some(sftp_uid) = self.terminal_sftp_windows.get(&uid).copied() { - if let Some(Tab { body: TabBody::Sftp(view), .. }) = - self.dock.iter_all_tabs_mut().map(|(_, t)| t).chain(self.detached.tabs_mut()).chain(self.mini_dock.tabs_mut()).find(|t| t.uid == sftp_uid) - { - view.set_linked_terminal(Some(terminal)); - view.browse_path(path); - } - self.apply_tab_actions(vec![crate::ui::vertical_tabs::TabAction::Focus(sftp_uid)], ctx); - return; + 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 profile = self.store.get(profile.id).cloned().unwrap_or(profile); - let mut view = self.spawn_sftp(&profile, secret::get(profile.secret_id()), ctx); + let view = tab.linked_sftp.as_mut()?.view.as_mut(); view.set_linked_terminal(Some(terminal)); - view.browse_path(path); - let tab = Tab::new(profile, TabBody::Sftp(Box::new(view))); - let sftp_uid = tab.uid; - let screen_pos = ctx.input(|i| i.viewport().outer_rect.map(|r| r.min + egui::vec2(70.0, 70.0))).unwrap_or(egui::pos2(120.0, 120.0)); - self.detached.pop_out(tab, screen_pos); - self.terminal_sftp_windows.insert(uid, sftp_uid); - ctx.request_repaint(); + 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. @@ -779,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 { 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/ui/sftp_session.rs b/src/ui/sftp_session.rs index 860f5c8..33478c0 100644 --- a/src/ui/sftp_session.rs +++ b/src/ui/sftp_session.rs @@ -318,6 +318,7 @@ impl SftpView { 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() @@ -326,14 +327,13 @@ impl SftpView { if let Some(cwd) = reported { cwd } else { - let conn = session.pool.get(&profile, secret::get(profile.secret_id())).await?; 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 = location_path(&path, &cwd, &home); + 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 @@ -395,19 +395,6 @@ impl SftpView { } } -fn location_path(path: &str, cwd: &str, home: &str) -> String { - let path = if path == "~" { - home.into() - } else if let Some(rest) = path.strip_prefix("~/") { - crate::ssh::sftp::join(home, rest) - } else if path.starts_with('/') { - path.into() - } else { - crate::ssh::sftp::join(cwd, path) - }; - super::path_complete::normalize_remote(&path) -} - /// The delay before automatic reconnect attempt `attempt` (1-based); `None` once they /// are used up. pub(super) fn retry_delay(attempt: u32) -> Option { @@ -465,13 +452,6 @@ pub(super) fn humanize(err: &anyhow::Error) -> String { mod tests { use super::*; - #[test] - fn terminal_locations_resolve_on_the_remote_host() { - assert_eq!(location_path("../other project", "/srv/project", "/home/dev"), "/srv/other project"); - assert_eq!(location_path("~/src", "/srv/project", "/home/dev"), "/home/dev/src"); - assert_eq!(location_path("~", "/srv/project", "/home/dev"), "/home/dev"); - assert_eq!(location_path("/etc/nginx", "/srv/project", "/home/dev"), "/etc/nginx"); - } use russh_sftp::protocol::Status; fn status(code: StatusCode) -> anyhow::Error { diff --git a/src/ui/term_links.rs b/src/ui/term_links.rs index 854265b..a2a34d4 100644 --- a/src/ui/term_links.rs +++ b/src/ui/term_links.rs @@ -501,7 +501,19 @@ pub fn find_link_in_str( let path_start = start + path_start; let path_end = start + path_end; if (path_start..path_end).contains(&byte_idx) { - return classify_candidate(&text[path_start..path_end], path_start, cwd, false, cache); + 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; } @@ -582,7 +594,7 @@ fn classify_candidate( } } 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(), @@ -682,7 +694,7 @@ fn looks_like_path_candidate(s: &str) -> bool { } fn is_remote_path_syntax(s: &str) -> bool { - if s == "~" || 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; } // Remote folders do not need an extension or a trailing slash. @@ -764,25 +776,29 @@ mod tests { #[test] fn ssh_prompt_links_only_the_directory_and_opens_sftp_on_click() { let mut cache = PathProbeCache::default(); - for (prompt, expected) in [ - ("prisma@na-prisma-02:~/servers/practice-servers/na-practice-01$", "~/servers/practice-servers/na-practice-01"), - ("root@server:/srv/app#", "/srv/app"), - ("[dev@host:~/repo]$", "~/repo"), - ("dev@host:~$", "~"), - ("root@host:/#", "/"), + 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: expected.into(), line: None }); + 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(expected.into())))); + 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", "/", "~"] { + 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}"); } } diff --git a/src/ui/terminal_view.rs b/src/ui/terminal_view.rs index b71fda0..5889186 100644 --- a/src/ui/terminal_view.rs +++ b/src/ui/terminal_view.rs @@ -1192,7 +1192,7 @@ mod tests { h.event(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); h.run_steps(2); } - assert_eq!(&*actions.lock().unwrap(), &["~/servers/practice-servers/na-practice-01"]); + 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); 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)); +}