diff --git a/docs/launch.rst b/docs/launch.rst index 8b8dd6219..2a392f80d 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -145,6 +145,10 @@ functions for the events you are interested in, for example: # when a title change was requested via escape code from the program # running in the terminal + def on_cmd_startstop(boss: Boss, window: Window, data: Dict[str, Any]) -> None: + # called when the shell starts/stops executing a command. Here + # data will contain is_start and time. + Every callback is passed a reference to the global ``Boss`` object as well as the ``Window`` object the action is occurring on. The ``data`` object is a dict that contains event dependent data. Some useful methods and attributes for the diff --git a/kitty/launch.py b/kitty/launch.py index a6a514d87..95cc15af8 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -398,6 +398,15 @@ def load_watch_modules(watchers: Iterable[str]) -> Optional[Watchers]: w = m.get('on_focus_change') if callable(w): ans.on_focus_change.append(w) + w = m.get('on_set_user_var') + if callable(w): + ans.on_set_user_var.append(w) + w = m.get('on_title_change') + if callable(w): + ans.on_title_change.append(w) + w = m.get('on_cmd_startstop') + if callable(w): + ans.on_cmd_startstop.append(w) return ans diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 1594a6060..3058cfb84 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3149,6 +3149,39 @@ ''' ) +opt('notify_on_cmd_finish', 'never', + choices=('never', 'unfocused', 'invisible', 'always'), + long_text=''' +Whether to send a desktop notification when a long-running command finishes +(needs :opt:`shell_integration`). +The possible values are: + +:code:`never` + Never send a notification. + +:code:`unfocused` + Only send a notification when the window does not have keyboard focus. + +:code:`invisible` + Only send a notification when the window both is unfocused and not visible + to the user, for example, because it is in an inactive tab or its OS window + is not currently active. + +:code:`always` + Always send a notification, even if the window is focused. +''' + ) + +opt('notify_on_cmd_finish_min_duration', '5.0', + option_type="float", + long_text=''' +Only send a notification if a command takes more than specified seconds of time. +It's not recommended to set a value too small, as you probably don't want a +notification when a command launches a new window and exit immediately (e.g. the +`code` command from vscode). +''' + ) + opt('term', 'xterm-kitty', long_text=''' The value of the :envvar:`TERM` environment variable to set. Changing this can diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 752680382..e07040f83 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1120,6 +1120,17 @@ def narrow_symbols(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: for k, v in narrow_symbols(val): ans["narrow_symbols"][k] = v + def notify_on_cmd_finish(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: + val = val.lower() + if val not in self.choices_for_notify_on_cmd_finish: + raise ValueError(f"The value {val} is not a valid choice for notify_on_cmd_finish") + ans["notify_on_cmd_finish"] = val + + choices_for_notify_on_cmd_finish = frozenset(('never', 'unfocused', 'invisible', 'always')) + + def notify_on_cmd_finish_min_duration(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: + ans['notify_on_cmd_finish_min_duration'] = float(val) + def open_url_with(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: ans['open_url_with'] = to_cmdline(val) diff --git a/kitty/options/types.py b/kitty/options/types.py index 6af13d6a1..41b80c118 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -20,6 +20,7 @@ choices_for_linux_display_server = typing.Literal['auto', 'wayland', 'x11'] choices_for_macos_colorspace = typing.Literal['srgb', 'default', 'displayp3'] choices_for_macos_show_window_title_in = typing.Literal['all', 'menubar', 'none', 'window'] +choices_for_notify_on_cmd_finish = typing.Literal['never', 'unfocused', 'invisible', 'always'] choices_for_placement_strategy = typing.Literal['center', 'top-left'] choices_for_pointer_shape_when_dragging = choices_for_default_pointer_shape choices_for_pointer_shape_when_grabbed = choices_for_default_pointer_shape @@ -386,6 +387,8 @@ 'mouse_hide_wait', 'mouse_map', 'narrow_symbols', + 'notify_on_cmd_finish', + 'notify_on_cmd_finish_min_duration', 'open_url_with', 'paste_actions', 'placement_strategy', @@ -545,6 +548,8 @@ class Options: mark3_background: Color = Color(242, 116, 188) mark3_foreground: Color = Color(0, 0, 0) mouse_hide_wait: float = 0.0 if is_macos else 3.0 + notify_on_cmd_finish: choices_for_notify_on_cmd_finish = 'never' + notify_on_cmd_finish_min_duration: float = 5.0 open_url_with: typing.List[str] = ['default'] paste_actions: typing.FrozenSet[str] = frozenset({'confirm', 'quote-urls-at-prompt'}) placement_strategy: choices_for_placement_strategy = 'center' diff --git a/kitty/screen.c b/kitty/screen.c index 4ddda5104..baf78ca7e 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -2216,9 +2216,12 @@ shell_prompt_marking(Screen *self, PyObject *data) { } if (PyErr_Occurred()) PyErr_Print(); self->linebuf->line_attrs[self->cursor->y].prompt_kind = pk; + if (pk == PROMPT_START) + CALLBACK("cmd_output_marking", "i", 0); } break; case 'C': self->linebuf->line_attrs[self->cursor->y].prompt_kind = OUTPUT_START; + CALLBACK("cmd_output_marking", "i", 1); break; } } diff --git a/kitty/window.py b/kitty/window.py index 75502ba06..68460e9c0 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -83,7 +83,9 @@ from .keys import keyboard_mode_name, mod_mask from .notify import ( NotificationCommand, + OnlyWhen, handle_notification_cmd, + notify_with_command, sanitize_identifier_pat, ) from .options.types import Options @@ -259,6 +261,7 @@ class Watchers: on_focus_change: List[Watcher] on_set_user_var: List[Watcher] on_title_change: List[Watcher] + on_cmd_startstop: List[Watcher] def __init__(self) -> None: self.on_resize = [] @@ -266,6 +269,7 @@ def __init__(self) -> None: self.on_focus_change = [] self.on_set_user_var = [] self.on_title_change = [] + self.on_cmd_startstop = [] def add(self, others: 'Watchers') -> None: def merge(base: List[Watcher], other: List[Watcher]) -> None: @@ -277,10 +281,11 @@ def merge(base: List[Watcher], other: List[Watcher]) -> None: merge(self.on_focus_change, others.on_focus_change) merge(self.on_set_user_var, others.on_set_user_var) merge(self.on_title_change, others.on_title_change) + merge(self.on_cmd_startstop, others.on_cmd_startstop) def clear(self) -> None: del self.on_close[:], self.on_resize[:], self.on_focus_change[:] - del self.on_set_user_var[:], self.on_title_change[:] + del self.on_set_user_var[:], self.on_title_change[:], self.on_cmd_startstop[:] def copy(self) -> 'Watchers': ans = Watchers() @@ -289,11 +294,13 @@ def copy(self) -> 'Watchers': ans.on_focus_change = self.on_focus_change[:] ans.on_set_user_var = self.on_set_user_var[:] ans.on_title_change = self.on_title_change[:] + ans.on_cmd_startstop = self.on_cmd_startstop[:] return ans @property def has_watchers(self) -> bool: - return bool(self.on_close or self.on_resize or self.on_focus_change) + return bool(self.on_close or self.on_resize or self.on_focus_change + or self.on_set_user_var or self.on_title_change or self.on_cmd_startstop) def call_watchers(windowref: Callable[[], Optional['Window']], which: str, data: Dict[str, Any]) -> None: @@ -536,6 +543,7 @@ def __init__( self.current_mouse_event_button = 0 self.current_clipboard_read_ask: Optional[bool] = None self.prev_osc99_cmd = NotificationCommand() + self.last_cmd_output_start_time = 0 self.actions_on_close: List[Callable[['Window'], None]] = [] self.actions_on_focus_change: List[Callable[['Window', bool], None]] = [] self.actions_on_removal: List[Callable[['Window'], None]] = [] @@ -1315,6 +1323,33 @@ def manipulate_title_stack(self, pop: bool, title: str, icon: Any) -> None: else: if self.child_title: self.title_stack.append(self.child_title) + + def cmd_output_marking(self, is_start: int) -> None: + if is_start: + start_time = monotonic() + self.last_cmd_output_start_time = start_time + + self.call_watchers(self.watchers.on_cmd_startstop, {"is_start": True, "time": start_time}) + else: + if self.last_cmd_output_start_time > 0: + end_time = monotonic() + last_cmd_output_duration = end_time - self.last_cmd_output_start_time + self.last_cmd_output_start_time = 0 + + self.call_watchers(self.watchers.on_cmd_startstop, {"is_start": False, "time": end_time}) + + opts = get_options() + mode = opts.notify_on_cmd_finish + if mode == 'never': + return + + if last_cmd_output_duration >= opts.notify_on_cmd_finish_min_duration: + cmd = NotificationCommand() + cmd.title = 'kitty' + cmd.body = 'Command finished in a background window.\nClick to focus.' + cmd.actions = 'focus' + cmd.only_when = OnlyWhen(mode) + notify_with_command(cmd, self.id) # }}} # mouse actions {{{