feat: Konsole parity phase 1 (save output, search options, monitors, profile CLI)

Implements the first batch of COSMIC_TERMINAL_KONSOLE_PARITY.md:

Save output as text:
- New SaveOutput action in the Edit menu, terminal context menu and
  keyboard shortcuts (Ctrl+Shift+S, Konsole parity, rebindable).
- Terminal::scrollback_text() extracts history plus visible screen via
  alacritty bounds_to_string, trimming trailing empty lines.
- Save-file dialog through cosmic-files; the file is written in a
  spawn_blocking task so large scrollbacks never stall the UI.

Search options:
- Case-sensitive and regex checkboxes in the find bar.
- App::find_pattern() escapes the pattern when regex mode is off and
  prefixes (?i) when case-insensitive (same approach as Alacritty).

Per-tab monitors (activity / silence / process finished):
- Toggles in the View menu and terminal context menu, state stored in
  Terminal (monitor_* fields).
- Activity alerts on PTY output (Wakeup) for non-active tabs; silence
  alerts after 10 s without output (Konsole default); process-finished
  compares the shell pgrp with the tty tpgid via /proc/<pid>/stat and
  alerts when the foreground job exits.
- Silence and process checks run on a 1 s iced::time subscription that
  is only active while at least one tab needs it.
- Tab bar shows an armed icon (view-reveal-symbolic) and per-kind alert
  icons; activating the tab acknowledges the alert.

Profile CLI:
- --profile/-p <name-or-id> applies a profile to the first tab only,
  unknown profiles exit(1) with the available list on stderr.
- --list-profiles prints "id<TAB>name" and exits.
- Config is now loaded before the daemonize fork so CLI output reaches
  the launching terminal.
- --help now documents -e/--command, --no-daemon and the new flags
  (-e already worked; audit confirmed everything after it becomes the
  command and its arguments).

i18n: new en/fr strings (save-output, monitor-*, find-case-sensitive,
find-regex).

Validated with ./check_cosmic_local.sh terminal, cargo fmt, and a debug
build exercising the CLI flags and a 6 s live run. The shutdown panic
in iced_winit ("async fn resumed after completion") pre-exists and is
reproducible with the installed binary.

Leyoda 2026 – GPLv3
This commit is contained in:
Lionel DARNIS 2026-07-06 09:50:58 +02:00
parent 5af952d285
commit 43d38aed8a
6 changed files with 529 additions and 42 deletions

View file

@ -104,6 +104,8 @@ type-to-search = Type to search...
find-placeholder = Find... find-placeholder = Find...
find-previous = Find previous find-previous = Find previous
find-next = Find next find-next = Find next
find-case-sensitive = Case sensitive
find-regex = Regex
# Menu # Menu
@ -123,6 +125,12 @@ paste = Paste
select-all = Select all select-all = Select all
find = Find find = Find
clear-scrollback = Clear scrollback clear-scrollback = Clear scrollback
save-output = Save output as text...
## Monitors
monitor-activity = Monitor for activity
monitor-silence = Monitor for silence
monitor-process-finished = Monitor for process finishing
## Open ## Open
open-link = Open Link open-link = Open Link

View file

@ -72,6 +72,8 @@ show-header-description = Révéler l'en-tête du menu contextuel
find-placeholder = Rechercher... find-placeholder = Rechercher...
find-previous = Chercher précédent find-previous = Chercher précédent
find-next = Chercher prochain find-next = Chercher prochain
find-case-sensitive = Respecter la casse
find-regex = Regex
# Menu # Menu
@ -94,6 +96,12 @@ paste = Coller
select-all = Sélectionner tout select-all = Sélectionner tout
find = Rechercher find = Rechercher
clear-scrollback = Effacer l'historique clear-scrollback = Effacer l'historique
save-output = Enregistrer la sortie comme texte...
## Monitors
monitor-activity = Surveiller l'activité
monitor-silence = Surveiller le silence
monitor-process-finished = Surveiller la fin du processus
## View ## View

View file

@ -65,7 +65,7 @@ mod localize;
use menu::menu_bar; use menu::menu_bar;
mod menu; mod menu;
use terminal::{Terminal, TerminalPaneGrid, TerminalScroll}; use terminal::{MONITOR_SILENCE_SECONDS, MonitorKind, Terminal, TerminalPaneGrid, TerminalScroll};
mod terminal; mod terminal;
use terminal_box::terminal_box; use terminal_box::terminal_box;
@ -89,6 +89,33 @@ pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon {
icon_cache.get(name, size) icon_cache.get(name, size)
} }
/// Tab bar indicator for the monitor state of a terminal:
/// an alert icon if one fired, an "armed" icon while monitoring, else none.
fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> {
match terminal.monitor_alert {
Some(MonitorKind::Activity) => Some("dialog-information-symbolic"),
Some(MonitorKind::Silence) => Some("alarm-symbolic"),
Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"),
None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"),
None => None,
}
}
fn monitor_update_tab_icon(
tab_model: &mut segmented_button::Model<segmented_button::SingleSelect>,
entity: segmented_button::Entity,
icon_name: Option<&'static str>,
) {
match icon_name {
Some(name) => {
tab_model.icon_set(entity, icon_cache_get(name, 16));
}
None => {
tab_model.icon_remove(entity);
}
}
}
/// Runs application with these settings /// Runs application with these settings
#[rustfmt::skip] #[rustfmt::skip]
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
@ -99,6 +126,8 @@ fn main() -> Result<(), Box<dyn Error>> {
let mut shell_args = Vec::new(); let mut shell_args = Vec::new();
let mut daemonize = true; let mut daemonize = true;
let mut working_directory = None; let mut working_directory = None;
let mut profile_name_opt = None;
let mut list_profiles = false;
// Parse the arguments using clap_lex // Parse the arguments using clap_lex
while let Some(arg) = raw_args.next_os(&mut cursor) { while let Some(arg) = raw_args.next_os(&mut cursor) {
match arg.to_str() { match arg.to_str() {
@ -121,6 +150,17 @@ fn main() -> Result<(), Box<dyn Error>> {
process::exit(1); process::exit(1);
} }
} }
Some(arg_str @ "--profile") | Some(arg_str @ "-p") => {
if let Some(profile_arg) = raw_args.next_os(&mut cursor) {
profile_name_opt = Some(profile_arg.to_string_lossy().to_string());
} else {
eprintln!("Missing argument for {arg_str}");
process::exit(1);
}
}
Some("--list-profiles") => {
list_profiles = true;
}
Some("--no-daemon") => { Some("--no-daemon") => {
daemonize = false; daemonize = false;
} }
@ -143,22 +183,8 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
} }
// Platform-specific daemonization logic // Load config before daemonizing so profile CLI options can print
// errors and listings to the launching terminal
#[cfg(all(unix, not(target_os = "redox")))]
if daemonize {
match fork::daemon(true, true) {
Ok(fork::Fork::Child) => (),
Ok(fork::Fork::Parent(_child_pid)) => process::exit(0),
Err(err) => {
eprintln!("failed to daemonize: {:?}", err);
process::exit(1);
}
}
}
localize::localize();
let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
Ok(config_handler) => { Ok(config_handler) => {
let config = match Config::get_entry(&config_handler) { let config = match Config::get_entry(&config_handler) {
@ -176,6 +202,53 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
}; };
if list_profiles {
for (name, profile_id) in config.profile_names() {
println!("{}\t{}", profile_id.0, name);
}
return Ok(());
}
let startup_profile_id = match &profile_name_opt {
Some(profile_name) => {
let profile_id_opt = config
.profile_names()
.into_iter()
.find(|(name, _)| name == profile_name)
.map(|(_, profile_id)| profile_id)
.or_else(|| {
// Fall back to matching a numeric profile ID
let id = profile_name.parse::<u64>().ok()?;
config.profiles.contains_key(&ProfileId(id)).then_some(ProfileId(id))
});
if profile_id_opt.is_none() {
eprintln!("Profile {profile_name:?} not found. Available profiles:");
for (name, profile_id) in config.profile_names() {
eprintln!("{}\t{}", profile_id.0, name);
}
process::exit(1);
}
profile_id_opt
}
None => None,
};
// Platform-specific daemonization logic
#[cfg(all(unix, not(target_os = "redox")))]
if daemonize {
match fork::daemon(true, true) {
Ok(fork::Fork::Child) => (),
Ok(fork::Fork::Parent(_child_pid)) => process::exit(0),
Err(err) => {
eprintln!("failed to daemonize: {:?}", err);
process::exit(1);
}
}
}
localize::localize();
let shortcuts_config = shortcuts::ShortcutsConfig::new(config.shortcuts_custom.clone()); let shortcuts_config = shortcuts::ShortcutsConfig::new(config.shortcuts_custom.clone());
let shell = shell_program_opt.map(|shell_program| tty::Shell::new(shell_program, shell_args)); let shell = shell_program_opt.map(|shell_program| tty::Shell::new(shell_program, shell_args));
@ -208,6 +281,7 @@ fn main() -> Result<(), Box<dyn Error>> {
config, config,
shortcuts_config, shortcuts_config,
startup_options, startup_options,
startup_profile_id,
term_config, term_config,
}; };
@ -226,7 +300,13 @@ Project home page: https://github.com/pop-os/cosmic-term
Options: Options:
--help Show this message --help Show this message
--version Show the version of cosmic-term --version Show the version of cosmic-term
-w, --working-directory <dir> Set the working directory for the terminal"# -w, --working-directory <dir> Set the working directory for the terminal
-p, --profile <name-or-id> Open the first tab with the given profile
--list-profiles List profiles (ID and name) and exit
--no-daemon Do not detach from the launching terminal
-e, --command <command...> Run a command instead of the default shell;
everything after this flag (or after `--`)
is treated as the command and its arguments"#
); );
} }
@ -236,6 +316,7 @@ pub struct Flags {
config: Config, config: Config,
shortcuts_config: shortcuts::ShortcutsConfig, shortcuts_config: shortcuts::ShortcutsConfig,
startup_options: Option<tty::Options>, startup_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>,
term_config: term::Config, term_config: term::Config,
} }
@ -251,6 +332,7 @@ pub enum Action {
Find, Find,
KeyboardShortcuts, KeyboardShortcuts,
LaunchUrlByMenu, LaunchUrlByMenu,
Monitor(MonitorKind),
PaneFocusDown, PaneFocusDown,
PaneFocusLeft, PaneFocusLeft,
PaneFocusRight, PaneFocusRight,
@ -262,6 +344,7 @@ pub enum Action {
PastePrimary, PastePrimary,
ProfileOpen(ProfileId), ProfileOpen(ProfileId),
Profiles, Profiles,
SaveOutput,
SelectAll, SelectAll,
Settings, Settings,
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
@ -305,6 +388,7 @@ impl Action {
Self::Find => Message::Find(true), Self::Find => Message::Find(true),
Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts), Self::KeyboardShortcuts => Message::ToggleContextPage(ContextPage::KeyboardShortcuts),
Self::LaunchUrlByMenu => Message::LaunchUrlByMenu, Self::LaunchUrlByMenu => Message::LaunchUrlByMenu,
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down), Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left), Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right), Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
@ -318,6 +402,7 @@ impl Action {
Self::PastePrimary => Message::PastePrimary(entity_opt), Self::PastePrimary => Message::PastePrimary(entity_opt),
Self::ProfileOpen(profile_id) => Message::ProfileOpen(*profile_id), Self::ProfileOpen(profile_id) => Message::ProfileOpen(*profile_id),
Self::Profiles => Message::ToggleContextPage(ContextPage::Profiles), Self::Profiles => Message::ToggleContextPage(ContextPage::Profiles),
Self::SaveOutput => Message::SaveOutput(entity_opt),
Self::SelectAll => Message::SelectAll(entity_opt), Self::SelectAll => Message::SelectAll(entity_opt),
Self::Settings => Message::ToggleContextPage(ContextPage::Settings), Self::Settings => Message::ToggleContextPage(ContextPage::Settings),
Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar), Self::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(*show_headerbar),
@ -384,8 +469,10 @@ pub enum Message {
DialogMessage(Box<DialogMessage>), // DialogMessage is huge, so we use a box to make the size of this enum smaller on the stack DialogMessage(Box<DialogMessage>), // DialogMessage is huge, so we use a box to make the size of this enum smaller on the stack
Drop(Option<(pane_grid::Pane, segmented_button::Entity, DndDrop)>), Drop(Option<(pane_grid::Pane, segmented_button::Entity, DndDrop)>),
Find(bool), Find(bool),
FindCaseSensitive(bool),
FindNext, FindNext,
FindPrevious, FindPrevious,
FindRegex(bool),
FindSearchValueChanged(String), FindSearchValueChanged(String),
MiddleClick(pane_grid::Pane, Option<segmented_button::Entity>), MiddleClick(pane_grid::Pane, Option<segmented_button::Entity>),
FocusFollowMouse(bool), FocusFollowMouse(bool),
@ -400,6 +487,8 @@ pub enum Message {
ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource), ShortcutRemove(shortcuts::Binding, shortcuts::BindingSource),
ShortcutReset(shortcuts::KeyBindAction), ShortcutReset(shortcuts::KeyBindAction),
ShortcutSearch(String), ShortcutSearch(String),
MonitorTick,
MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
MouseEnter(pane_grid::Pane), MouseEnter(pane_grid::Pane),
Opacity(u8), Opacity(u8),
PaneClicked(pane_grid::Pane), PaneClicked(pane_grid::Pane),
@ -427,6 +516,8 @@ pub enum Message {
ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize), ProfileSyntaxTheme(ProfileId, ColorSchemeKind, usize),
ProfileTabTitle(ProfileId, String), ProfileTabTitle(ProfileId, String),
ReorderTab(Pane, ReorderEvent), ReorderTab(Pane, ReorderEvent),
SaveOutput(Option<segmented_button::Entity>),
SaveOutputResult(pane_grid::Pane, segmented_button::Entity, DialogResult),
Surface(surface::Action), Surface(surface::Action),
SelectAll(Option<segmented_button::Entity>), SelectAll(Option<segmented_button::Entity>),
ShowAdvancedFontSettings(bool), ShowAdvancedFontSettings(bool),
@ -512,11 +603,14 @@ pub struct App {
dialog_opt: Option<Dialog<Message>>, dialog_opt: Option<Dialog<Message>>,
terminal_ids: HashMap<pane_grid::Pane, widget::Id>, terminal_ids: HashMap<pane_grid::Pane, widget::Id>,
find: bool, find: bool,
find_case_sensitive: bool,
find_regex: bool,
find_search_id: widget::Id, find_search_id: widget::Id,
find_search_value: String, find_search_value: String,
term_event_tx_opt: term_event_tx_opt:
Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>, Option<mpsc::UnboundedSender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>,
startup_options: Option<tty::Options>, startup_options: Option<tty::Options>,
startup_profile_id: Option<ProfileId>,
term_config: term::Config, term_config: term::Config,
color_scheme_errors: Vec<String>, color_scheme_errors: Vec<String>,
color_scheme_expanded: Option<(ColorSchemeKind, Option<ColorSchemeId>)>, color_scheme_expanded: Option<(ColorSchemeKind, Option<ColorSchemeId>)>,
@ -1554,6 +1648,60 @@ impl App {
terminal.working_directory() terminal.working_directory()
} }
/// Build the regex pattern for the find bar based on the current options.
fn find_pattern(&self) -> String {
let mut pattern = if self.find_regex {
self.find_search_value.clone()
} else {
regex::escape(&self.find_search_value)
};
if !self.find_case_sensitive {
pattern = format!("(?i){pattern}");
}
pattern
}
fn terminal_monitors(
&self,
pane: pane_grid::Pane,
entity: segmented_button::Entity,
) -> (bool, bool, bool) {
if let Some(tab_model) = self.pane_model.panes.get(pane)
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
{
let terminal = terminal.lock().unwrap();
(
terminal.monitor_activity,
terminal.monitor_silence,
terminal.monitor_process_finished,
)
} else {
(false, false, false)
}
}
fn active_terminal_monitors(&self) -> (bool, bool, bool) {
let pane = self.pane_model.focused();
if let Some(tab_model) = self.pane_model.panes.get(pane) {
self.terminal_monitors(pane, tab_model.active())
} else {
(false, false, false)
}
}
fn any_monitor_needs_tick(&self) -> bool {
self.pane_model.panes.iter().any(|(_pane, tab_model)| {
tab_model.iter().any(|entity| {
tab_model
.data::<Mutex<Terminal>>(entity)
.is_some_and(|terminal| {
let terminal = terminal.lock().unwrap();
terminal.monitor_silence || terminal.monitor_process_finished
})
})
})
}
fn create_and_focus_new_terminal( fn create_and_focus_new_terminal(
&mut self, &mut self,
pane: pane_grid::Pane, pane: pane_grid::Pane,
@ -1887,9 +2035,12 @@ impl Application for App {
dialog_opt: None, dialog_opt: None,
terminal_ids, terminal_ids,
find: false, find: false,
find_case_sensitive: false,
find_regex: false,
find_search_id: widget::Id::unique(), find_search_id: widget::Id::unique(),
find_search_value: String::new(), find_search_value: String::new(),
startup_options: flags.startup_options, startup_options: flags.startup_options,
startup_profile_id: flags.startup_profile_id,
term_config: flags.term_config, term_config: flags.term_config,
term_event_tx_opt: None, term_event_tx_opt: None,
color_scheme_errors: Vec::new(), color_scheme_errors: Vec::new(),
@ -2401,14 +2552,19 @@ impl Application for App {
// Focus correct input // Focus correct input
return self.update_focus(); return self.update_focus();
} }
Message::FindCaseSensitive(case_sensitive) => {
self.find_case_sensitive = case_sensitive;
return self.update_focus();
}
Message::FindNext => { Message::FindNext => {
if !self.find_search_value.is_empty() if !self.find_search_value.is_empty() {
&& let Some(tab_model) = self.pane_model.active() let pattern = self.find_pattern();
{ if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active(); let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap(); let mut terminal = terminal.lock().unwrap();
terminal.search(&self.find_search_value, true); terminal.search(&pattern, true);
}
} }
} }
@ -2416,19 +2572,24 @@ impl Application for App {
return self.update_focus(); return self.update_focus();
} }
Message::FindPrevious => { Message::FindPrevious => {
if !self.find_search_value.is_empty() if !self.find_search_value.is_empty() {
&& let Some(tab_model) = self.pane_model.active() let pattern = self.find_pattern();
{ if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active(); let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) { if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap(); let mut terminal = terminal.lock().unwrap();
terminal.search(&self.find_search_value, false); terminal.search(&pattern, false);
}
} }
} }
// Focus correct input // Focus correct input
return self.update_focus(); return self.update_focus();
} }
Message::FindRegex(regex) => {
self.find_regex = regex;
return self.update_focus();
}
Message::FindSearchValueChanged(value) => { Message::FindSearchValueChanged(value) => {
self.find_search_value = value; self.find_search_value = value;
} }
@ -2533,6 +2694,79 @@ impl Application for App {
Message::Modifiers(modifiers) => { Message::Modifiers(modifiers) => {
self.modifiers = modifiers; self.modifiers = modifiers;
} }
Message::MonitorTick => {
let panes: Vec<pane_grid::Pane> = self
.pane_model
.panes
.iter()
.map(|(pane, _)| *pane)
.collect();
for pane in panes {
let Some(tab_model) = self.pane_model.panes.get_mut(pane) else {
continue;
};
let entities: Vec<segmented_button::Entity> = tab_model.iter().collect();
for entity in entities {
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
// Silence: alert when no output for the configured delay
if terminal.monitor_silence
&& terminal.monitor_alert.is_none()
&& terminal.monitor_last_output.elapsed().as_secs()
>= MONITOR_SILENCE_SECONDS
{
terminal.monitor_alert = Some(MonitorKind::Silence);
icon_update = Some(monitor_icon_name(&terminal));
}
// Process finished: alert when the foreground job goes away
if terminal.monitor_process_finished {
let job_running =
terminal.foreground_job_running().unwrap_or(false);
if terminal.monitor_job_running && !job_running {
terminal.monitor_alert = Some(MonitorKind::ProcessFinished);
icon_update = Some(monitor_icon_name(&terminal));
}
terminal.monitor_job_running = job_running;
}
}
if let Some(icon_name) = icon_update {
monitor_update_tab_icon(tab_model, entity, icon_name);
}
}
}
}
Message::MonitorToggle(monitor_kind, entity_opt) => {
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
match monitor_kind {
MonitorKind::Activity => {
terminal.monitor_activity = !terminal.monitor_activity;
}
MonitorKind::Silence => {
terminal.monitor_silence = !terminal.monitor_silence;
terminal.monitor_last_output = std::time::Instant::now();
}
MonitorKind::ProcessFinished => {
terminal.monitor_process_finished =
!terminal.monitor_process_finished;
terminal.monitor_job_running =
terminal.foreground_job_running().unwrap_or(false);
}
}
terminal.monitor_alert = None;
icon_update = Some(monitor_icon_name(&terminal));
}
if let Some(icon_name) = icon_update {
monitor_update_tab_icon(tab_model, entity, icon_name);
}
}
}
Message::MouseEnter(pane) => { Message::MouseEnter(pane) => {
self.pane_model.set_focus(pane); self.pane_model.set_focus(pane);
return self.update_focus(); return self.update_focus();
@ -2772,6 +3006,57 @@ impl Application for App {
return self.save_profiles(); return self.save_profiles();
} }
} }
Message::SaveOutput(entity_opt) => {
if self.dialog_opt.is_none() {
let pane = self.pane_model.focused();
if let Some(tab_model) = self.pane_model.panes.get(pane) {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
let filename = format!(
"{}.txt",
tab_model
.text(entity)
.map(|title| title.replace(['/', '\0'], "-"))
.unwrap_or_else(|| fl!("new-terminal"))
);
let (dialog, command) = Dialog::new(
DialogSettings::new().kind(DialogKind::SaveFile { filename }),
|msg| Message::DialogMessage(Box::new(msg)),
move |result| Message::SaveOutputResult(pane, entity, result),
);
self.dialog_opt = Some(dialog);
return command;
}
}
}
Message::SaveOutputResult(pane, entity, result) => {
self.dialog_opt = None;
if let DialogResult::Open(paths) = result {
let path = paths[0].clone();
if let Some(tab_model) = self.pane_model.panes.get(pane)
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
{
let text = terminal.lock().unwrap().scrollback_text();
// Write in a blocking task to avoid stalling the UI on
// large scrollbacks or slow storage
return Task::perform(
async move {
match tokio::task::spawn_blocking(move || fs::write(&path, text))
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
log::error!("failed to save terminal output: {}", err);
}
Err(err) => {
log::error!("failed to save terminal output: {}", err);
}
}
},
|()| action::none(),
);
}
}
}
Message::SelectAll(entity_opt) => { Message::SelectAll(entity_opt) => {
if let Some(tab_model) = self.pane_model.active() { if let Some(tab_model) = self.pane_model.active() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active()); let entity = entity_opt.unwrap_or_else(|| tab_model.active());
@ -2827,6 +3112,19 @@ impl Application for App {
Message::TabActivate(entity) => { Message::TabActivate(entity) => {
if let Some(tab_model) = self.pane_model.active_mut() { if let Some(tab_model) = self.pane_model.active_mut() {
tab_model.activate(entity); tab_model.activate(entity);
// Viewing the tab acknowledges any monitor alert
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
if terminal.monitor_alert.is_some() {
terminal.monitor_alert = None;
icon_update = Some(monitor_icon_name(&terminal));
}
}
if let Some(icon_name) = icon_update {
monitor_update_tab_icon(tab_model, entity, icon_name);
}
} }
return self.update_title(None); return self.update_title(None);
} }
@ -3006,9 +3304,14 @@ impl Application for App {
return cosmic::Task::batch(tasks); return cosmic::Task::batch(tasks);
} }
Message::TabNew => { Message::TabNew => {
// The profile from `--profile` only applies to the first tab
let profile_id_opt = self
.startup_profile_id
.take()
.or_else(|| self.get_default_profile());
return self.create_and_focus_new_terminal( return self.create_and_focus_new_terminal(
self.pane_model.focused(), self.pane_model.focused(),
self.get_default_profile(), profile_id_opt,
self.config.tab_new_inherit_working_directory, self.config.tab_new_inherit_working_directory,
); );
} }
@ -3199,7 +3502,7 @@ impl Application for App {
} }
return self.update_title(Some(pane)); return self.update_title(Some(pane));
} }
TermEvent::MouseCursorDirty | TermEvent::Wakeup => { TermEvent::MouseCursorDirty => {
if let Some(tab_model) = self.pane_model.panes.get(pane) if let Some(tab_model) = self.pane_model.panes.get(pane)
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) && let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
{ {
@ -3207,6 +3510,33 @@ impl Application for App {
terminal.needs_update = true; terminal.needs_update = true;
} }
} }
TermEvent::Wakeup => {
if let Some(tab_model) = self.pane_model.panes.get_mut(pane) {
let is_active_tab = tab_model.active() == entity;
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.needs_update = true;
if terminal.monitoring_enabled() {
terminal.monitor_last_output = std::time::Instant::now();
if terminal.monitor_alert == Some(MonitorKind::Silence) {
terminal.monitor_alert = None;
icon_update = Some(monitor_icon_name(&terminal));
}
if terminal.monitor_activity
&& !is_active_tab
&& terminal.monitor_alert.is_none()
{
terminal.monitor_alert = Some(MonitorKind::Activity);
icon_update = Some(monitor_icon_name(&terminal));
}
}
}
if let Some(icon_name) = icon_update {
monitor_update_tab_icon(tab_model, entity, icon_name);
}
}
}
TermEvent::ChildExit(_error_code) => { TermEvent::ChildExit(_error_code) => {
//Ignore this for now //Ignore this for now
} }
@ -3483,7 +3813,12 @@ impl Application for App {
} }
fn header_start(&self) -> Vec<Element<'_, Self::Message>> { fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
vec![menu_bar(&self.core, &self.config, &self.key_binds)] vec![menu_bar(
&self.core,
&self.config,
&self.key_binds,
self.active_terminal_monitors(),
)]
} }
fn header_end(&self) -> Vec<Element<'_, Self::Message>> { fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
@ -3506,12 +3841,18 @@ impl Application for App {
} }
fn view_window(&self, window_id: window::Id) -> Element<'_, Message> { fn view_window(&self, window_id: window::Id) -> Element<'_, Message> {
if let Some((popup_id, _pane, entity, ref link, ref autosize_id, _)) = if let Some((popup_id, pane, entity, ref link, ref autosize_id, _)) =
self.context_menu_popup self.context_menu_popup
&& window_id == popup_id && window_id == popup_id
{ {
return widget::autosize::autosize( return widget::autosize::autosize(
menu::context_menu(&self.config, &self.key_binds, entity, link.clone()), menu::context_menu(
&self.config,
&self.key_binds,
entity,
link.clone(),
self.terminal_monitors(pane, entity),
),
autosize_id.clone(), autosize_id.clone(),
) )
.into(); .into();
@ -3546,7 +3887,9 @@ impl Application for App {
cosmic::iced::widget::container::Style { cosmic::iced::widget::container::Style {
icon_color: Some(Color::from(cosmic.background(false).on)), icon_color: Some(Color::from(cosmic.background(false).on)),
text_color: Some(Color::from(cosmic.background(false).on)), text_color: Some(Color::from(cosmic.background(false).on)),
background: Some(iced::Background::Color(cosmic.background(false).base.into())), background: Some(iced::Background::Color(
cosmic.background(false).base.into(),
)),
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
snap: true, snap: true,
@ -3611,6 +3954,7 @@ impl Application for App {
&self.key_binds, &self.key_binds,
popup_entity, popup_entity,
link.clone(), link.clone(),
self.terminal_monitors(popup_pane, popup_entity),
)) ))
.position(widget::popover::Position::Point(point)); .position(widget::popover::Position::Point(point));
popover.into() popover.into()
@ -3670,6 +4014,14 @@ impl Application for App {
widget::tooltip::Position::Top, widget::tooltip::Position::Top,
) )
.into(), .into(),
widget::checkbox(self.find_case_sensitive)
.label(fl!("find-case-sensitive"))
.on_toggle(Message::FindCaseSensitive)
.into(),
widget::checkbox(self.find_regex)
.label(fl!("find-regex"))
.on_toggle(Message::FindRegex)
.into(),
widget::space::horizontal().into(), widget::space::horizontal().into(),
button::custom(icon_cache_get("window-close-symbolic", 16)) button::custom(icon_cache_get("window-close-symbolic", 16))
.on_press(Message::Find(false)) .on_press(Message::Find(false))
@ -3723,7 +4075,7 @@ impl Application for App {
struct ConfigSubscription; struct ConfigSubscription;
struct TerminalEventSubscription; struct TerminalEventSubscription;
Subscription::batch([ let mut subscriptions = vec![
event::listen_with(|event, _status, _window_id| match event { event::listen_with(|event, _status, _window_id| match event {
Event::Keyboard(KeyEvent::KeyPressed { Event::Keyboard(KeyEvent::KeyPressed {
key, key,
@ -3776,7 +4128,16 @@ impl Application for App {
Some(dialog) => dialog.subscription(), Some(dialog) => dialog.subscription(),
None => Subscription::none(), None => Subscription::none(),
}, },
]) ];
// Timer driving silence and process-finished monitors
if self.any_monitor_needs_tick() {
subscriptions.push(
iced::time::every(std::time::Duration::from_secs(1)).map(|_| Message::MonitorTick),
);
}
Subscription::batch(subscriptions)
} }
} }

View file

@ -18,7 +18,7 @@ use cosmic::{
}; };
use std::{collections::HashMap, sync::LazyLock}; use std::{collections::HashMap, sync::LazyLock};
use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl}; use crate::{Action, ColorSchemeId, ColorSchemeKind, Config, Message, fl, terminal::MonitorKind};
static MENU_ID: LazyLock<cosmic::widget::Id> = static MENU_ID: LazyLock<cosmic::widget::Id> =
LazyLock::new(|| cosmic::widget::Id::new("responsive-menu")); LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
@ -35,6 +35,7 @@ pub fn context_menu<'a>(
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
entity: segmented_button::Entity, entity: segmented_button::Entity,
link: Option<String>, link: Option<String>,
monitors: (bool, bool, bool),
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let find_key = |action: &Action| -> String { let find_key = |action: &Action| -> String {
for (key_bind, key_action) in key_binds { for (key_bind, key_action) in key_binds {
@ -83,6 +84,23 @@ pub fn context_menu<'a>(
Element::from(menu_item(fl!("select-all"), Action::SelectAll)), Element::from(menu_item(fl!("select-all"), Action::SelectAll)),
Element::from(divider::horizontal::light()), Element::from(divider::horizontal::light()),
Element::from(menu_item(fl!("clear-scrollback"), Action::ClearScrollback)), Element::from(menu_item(fl!("clear-scrollback"), Action::ClearScrollback)),
Element::from(menu_item(fl!("save-output"), Action::SaveOutput)),
Element::from(divider::horizontal::light()),
Element::from(menu_checkbox(
fl!("monitor-activity"),
monitors.0,
Action::Monitor(MonitorKind::Activity),
)),
Element::from(menu_checkbox(
fl!("monitor-silence"),
monitors.1,
Action::Monitor(MonitorKind::Silence),
)),
Element::from(menu_checkbox(
fl!("monitor-process-finished"),
monitors.2,
Action::Monitor(MonitorKind::ProcessFinished),
)),
Element::from(divider::horizontal::light()), Element::from(divider::horizontal::light()),
Element::from(menu_item( Element::from(menu_item(
fl!("split-horizontal"), fl!("split-horizontal"),
@ -200,6 +218,7 @@ pub fn menu_bar<'a>(
core: &Core, core: &Core,
config: &Config, config: &Config,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
monitors: (bool, bool, bool),
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut profile_items = Vec::with_capacity(config.profiles.len()); let mut profile_items = Vec::with_capacity(config.profiles.len());
for (name, id) in config.profile_names() { for (name, id) in config.profile_names() {
@ -243,6 +262,7 @@ pub fn menu_bar<'a>(
MenuItem::Button(fl!("select-all"), None, Action::SelectAll), MenuItem::Button(fl!("select-all"), None, Action::SelectAll),
MenuItem::Divider, MenuItem::Divider,
MenuItem::Button(fl!("clear-scrollback"), None, Action::ClearScrollback), MenuItem::Button(fl!("clear-scrollback"), None, Action::ClearScrollback),
MenuItem::Button(fl!("save-output"), None, Action::SaveOutput),
MenuItem::Divider, MenuItem::Divider,
MenuItem::Button(fl!("find"), None, Action::Find), MenuItem::Button(fl!("find"), None, Action::Find),
], ],
@ -269,6 +289,25 @@ pub fn menu_bar<'a>(
Action::PaneToggleMaximized, Action::PaneToggleMaximized,
), ),
MenuItem::Divider, MenuItem::Divider,
MenuItem::CheckBox(
fl!("monitor-activity"),
None,
monitors.0,
Action::Monitor(MonitorKind::Activity),
),
MenuItem::CheckBox(
fl!("monitor-silence"),
None,
monitors.1,
Action::Monitor(MonitorKind::Silence),
),
MenuItem::CheckBox(
fl!("monitor-process-finished"),
None,
monitors.2,
Action::Monitor(MonitorKind::ProcessFinished),
),
MenuItem::Divider,
MenuItem::Button( MenuItem::Button(
fl!("menu-color-schemes"), fl!("menu-color-schemes"),
None, None,

View file

@ -72,6 +72,7 @@ pub enum KeyBindAction {
PastePrimary, PastePrimary,
#[cfg_attr(not(feature = "password_manager"), allow(dead_code))] #[cfg_attr(not(feature = "password_manager"), allow(dead_code))]
PasswordManager, PasswordManager,
SaveOutput,
SelectAll, SelectAll,
Settings, Settings,
TabActivate0, TabActivate0,
@ -113,6 +114,7 @@ impl KeyBindAction {
Self::PaneToggleMaximized => Some(Action::PaneToggleMaximized), Self::PaneToggleMaximized => Some(Action::PaneToggleMaximized),
Self::Paste => Some(Action::Paste), Self::Paste => Some(Action::Paste),
Self::PastePrimary => Some(Action::PastePrimary), Self::PastePrimary => Some(Action::PastePrimary),
Self::SaveOutput => Some(Action::SaveOutput),
Self::SelectAll => Some(Action::SelectAll), Self::SelectAll => Some(Action::SelectAll),
Self::Settings => Some(Action::Settings), Self::Settings => Some(Action::Settings),
Self::TabActivate0 => Some(Action::TabActivate0), Self::TabActivate0 => Some(Action::TabActivate0),
@ -275,6 +277,7 @@ pub fn action_label(action: KeyBindAction) -> String {
KeyBindAction::Paste => fl!("paste"), KeyBindAction::Paste => fl!("paste"),
KeyBindAction::PastePrimary => fl!("paste-primary"), KeyBindAction::PastePrimary => fl!("paste-primary"),
KeyBindAction::PasswordManager => fl!("password-manager"), KeyBindAction::PasswordManager => fl!("password-manager"),
KeyBindAction::SaveOutput => fl!("save-output"),
KeyBindAction::SelectAll => fl!("select-all"), KeyBindAction::SelectAll => fl!("select-all"),
KeyBindAction::Settings => fl!("settings"), KeyBindAction::Settings => fl!("settings"),
KeyBindAction::TabActivate0 => fl!("tab-activate", number = 1), KeyBindAction::TabActivate0 => fl!("tab-activate", number = 1),
@ -366,7 +369,7 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
KeyBindAction::ZoomReset, KeyBindAction::ZoomReset,
], ],
}); });
let mut other_actions = vec![KeyBindAction::ClearScrollback]; let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput];
#[cfg(feature = "password_manager")] #[cfg(feature = "password_manager")]
other_actions.push(KeyBindAction::PasswordManager); other_actions.push(KeyBindAction::PasswordManager);
groups.push(ShortcutGroup { groups.push(ShortcutGroup {
@ -459,6 +462,7 @@ fn fallback_shortcuts() -> Shortcuts {
bind!([Shift], "Insert", PastePrimary); bind!([Shift], "Insert", PastePrimary);
bind!([Ctrl, Shift], "W", TabClose); bind!([Ctrl, Shift], "W", TabClose);
bind!([Ctrl, Shift], "R", TabRename); bind!([Ctrl, Shift], "R", TabRename);
bind!([Ctrl, Shift], "S", SaveOutput);
bind!([Ctrl], ",", Settings); bind!([Ctrl], ",", Settings);
bind!([], "F11", ToggleFullscreen); bind!([], "F11", ToggleFullscreen);

View file

@ -54,6 +54,17 @@ pub const MIN_CURSOR_CONTRAST: f64 = 1.5;
/// A regex expression can start or end outside the visible screen. Therefore, without this constant, some regular expressions would not match at the top and bottom. /// A regex expression can start or end outside the visible screen. Therefore, without this constant, some regular expressions would not match at the top and bottom.
pub const MAX_SEARCH_LINES: usize = 100; pub const MAX_SEARCH_LINES: usize = 100;
/// Kinds of session monitoring, modeled after Konsole's monitor options.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MonitorKind {
Activity,
Silence,
ProcessFinished,
}
/// Seconds without output before a silence alert fires (Konsole default).
pub const MONITOR_SILENCE_SECONDS: u64 = 10;
/// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39 /// https://github.com/alacritty/alacritty/blob/4a7728bf7fac06a35f27f6c4f31e0d9214e5152b/alacritty/src/config/ui_config.rs#L36-L39
fn url_regex_search() -> RegexSearch { fn url_regex_search() -> RegexSearch {
let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\ let url_regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)\
@ -240,6 +251,12 @@ impl Metadata {
pub struct Terminal { pub struct Terminal {
pub context_menu: Option<MenuState>, pub context_menu: Option<MenuState>,
pub metadata_set: IndexSet<Metadata>, pub metadata_set: IndexSet<Metadata>,
pub monitor_activity: bool,
pub monitor_silence: bool,
pub monitor_process_finished: bool,
pub monitor_alert: Option<MonitorKind>,
pub monitor_last_output: Instant,
pub monitor_job_running: bool,
pub needs_update: bool, pub needs_update: bool,
pub profile_id_opt: Option<ProfileId>, pub profile_id_opt: Option<ProfileId>,
pub tab_title_override: Option<String>, pub tab_title_override: Option<String>,
@ -353,6 +370,12 @@ impl Terminal {
default_attrs, default_attrs,
dim_font_weight: Weight(dim_font_weight), dim_font_weight: Weight(dim_font_weight),
metadata_set, metadata_set,
monitor_activity: false,
monitor_silence: false,
monitor_process_finished: false,
monitor_alert: None,
monitor_last_output: Instant::now(),
monitor_job_running: false,
mouse_reporter: Default::default(), mouse_reporter: Default::default(),
needs_update: true, needs_update: true,
notifier, notifier,
@ -620,6 +643,50 @@ impl Terminal {
self.update(); self.update();
} }
/// Extract the entire scrollback plus visible screen as plain text,
/// trimming trailing empty lines.
pub fn scrollback_text(&self) -> String {
let term = self.term.lock();
let grid = term.grid();
let start = Point::new(Line(-(grid.history_size() as i32)), Column(0));
let mut end_line = grid.bottommost_line();
while end_line.0 > 0 {
if !grid[end_line].is_clear() {
break;
}
end_line.0 -= 1;
}
let end = Point::new(end_line, Column(grid.columns() - 1));
term.bounds_to_string(start, end)
}
pub fn monitoring_enabled(&self) -> bool {
self.monitor_activity || self.monitor_silence || self.monitor_process_finished
}
/// Whether a foreground job (other than the shell itself) is running on
/// the terminal. Compares the process group of the shell with the
/// foreground process group of the controlling terminal.
pub fn foreground_job_running(&self) -> Option<bool> {
#[cfg(target_os = "linux")]
{
let shell_pid = self.shell_pid?;
let stat = fs::read_to_string(format!("/proc/{shell_pid}/stat")).ok()?;
// Fields after the last ')' to handle process names with spaces:
// state ppid pgrp session tty_nr tpgid ...
let after_comm = stat.rsplit_once(')')?.1;
let mut fields = after_comm.split_whitespace();
let pgrp = fields.nth(2)?;
let tpgid = fields.nth(2)?;
Some(pgrp != tpgid)
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
pub fn select_all(&mut self) { pub fn select_all(&mut self) {
{ {
let mut term = self.term.lock(); let mut term = self.term.lock();