From 0d68a21be55fa14bd8a255f1bbab0c858fed61dc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 9 May 2024 09:49:26 +0530 Subject: [PATCH] notify_on_cmd_finish: Show the actual command that was finished Fixes #7420 --- docs/changelog.rst | 4 + docs/launch.rst | 2 +- docs/shell-integration.rst | 14 +++ kitty/options/definition.py | 3 +- kitty/screen.c | 17 +++- kitty/window.py | 91 ++++++++++++------- shell-integration/bash/kitty.bash | 32 ++++--- .../kitty-shell-integration.fish | 2 +- shell-integration/zsh/kitty-integration | 14 +-- 9 files changed, 123 insertions(+), 56 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a086c18cd..476d11a84 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -53,6 +53,10 @@ Detailed list of changes 0.35.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- :opt:`notify_on_cmd_finish`: Show the actual command that was finished (:iss:`7420`) + +- Shell integration: Make the currently executing cmdline available as a window variable in kitty + - :opt:`paste_actions`: Fix ``replace-newline`` not working with ``confirm`` (:iss:`7374`) - Graphics: Fix aspect ratio of images not being preserved when only a single diff --git a/docs/launch.rst b/docs/launch.rst index 8bc5d1e81..8e67fd642 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -147,7 +147,7 @@ functions for the events you are interested in, for example: 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. + # data will contain is_start, cmdline 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 diff --git a/docs/shell-integration.rst b/docs/shell-integration.rst index da85ee066..4043d7373 100644 --- a/docs/shell-integration.rst +++ b/docs/shell-integration.rst @@ -433,6 +433,10 @@ Just before running a command/program, send the escape code:: 133;C +Optionally, when a command is finished its "exit status" can be reported as:: + + 133;D;exit status as base 10 integer + Here ```` is the bytes ``0x1b 0x5d`` and ```` is the bytes ``0x1b 0x5c``. This is exactly what is needed for shell integration in kitty. For the full protocol, that also marks the command region, see `the iTerm2 docs @@ -451,3 +455,13 @@ to control its behavior, separated by semi-colons. They are:: k=s - this tells kitty that the secondary (PS2) prompt is starting at the current line. + +kitty also optionally supports sending the cmdline going to be executed with ``133;C`` as:: + + 133;C;cmdline=cmdline as space separated hex encoded text + or + 133;C;cmdline_url=cmdline as UTF-8 URL escaped text + + +Here, *space separated hex encoded text* means every unicode codepoint of the +command line is encoded as 2-8 hex digits separated by spaces. diff --git a/kitty/options/definition.py b/kitty/options/definition.py index e84c2a002..aebc28e1a 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3216,7 +3216,8 @@ Some more examples:: # Ring a bell when a command takes more than 10 seconds in a invisible window notify_on_cmd_finish invisible 10.0 bell # Run 'notify-send' when a command takes more than 10 seconds in a invisible window - notify_on_cmd_finish invisible 10.0 command notify-send job finished + # Here %c is replaced by the current command line and %s by the job exit code + notify_on_cmd_finish invisible 10.0 command notify-send "job finished with status: %s" %c ''' ) diff --git a/kitty/screen.c b/kitty/screen.c index 6f97f1152..6811c6f86 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -2335,13 +2335,20 @@ shell_prompt_marking(Screen *self, char *buf) { self->prompt_settings.uses_special_keys_for_cursor_movement = 0; parse_prompt_mark(self, buf+1, &pk); self->linebuf->line_attrs[self->cursor->y].prompt_kind = pk; - if (pk == PROMPT_START) - CALLBACK("cmd_output_marking", "O", Py_False); + if (pk == PROMPT_START) CALLBACK("cmd_output_marking", "O", Py_False); } break; - case 'C': + case 'C': { self->linebuf->line_attrs[self->cursor->y].prompt_kind = OUTPUT_START; - CALLBACK("cmd_output_marking", "O", Py_True); - break; + const char *cmdline = ""; + if (strstr(buf + 1, ";cmdline") == buf + 1) { + cmdline = buf + 2; + } + CALLBACK("cmd_output_marking", "Os", Py_True, cmdline); + } break; + case 'D': { + const char *exit_status = buf[1] == ';' ? buf + 2 : ""; + CALLBACK("cmd_output_marking", "Os", Py_None, exit_status); + } break; } } } diff --git a/kitty/window.py b/kitty/window.py index b6676bd44..2eabf6306 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -217,6 +217,16 @@ def compile_match_query(exp: str, is_simple: bool = True) -> MatchPatternType: return pat +def decode_cmdline(x: str) -> str: + ctype, sep, val = x.partition('=') + if ctype == 'cmdline': + return ''.join(chr(int(x, 16)) for x in val.split()) + if ctype == 'cmdline_url': + from urllib.parse import unquote + return unquote(val) + return '' + + class WindowDict(TypedDict): id: int is_focused: bool @@ -225,6 +235,8 @@ class WindowDict(TypedDict): pid: Optional[int] cwd: str cmdline: List[str] + last_reported_cmdline: str + last_cmd_exit_status: int env: Dict[str, str] foreground_processes: List[ProcessDesc] is_self: bool @@ -550,6 +562,8 @@ class Window: self.current_clipboard_read_ask: Optional[bool] = None self.prev_osc99_cmd = NotificationCommand() self.last_cmd_output_start_time = 0. + self.last_cmd_cmdline = '' + self.last_cmd_exit_status = 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]] = [] @@ -680,6 +694,8 @@ class Window: 'pid': self.child.pid, 'cwd': self.child.current_cwd or self.child.cwd, 'cmdline': self.child.cmdline, + 'last_reported_cmdline': self.last_cmd_cmdline, + 'last_cmd_exit_status': self.last_cmd_exit_status, 'env': self.child.environ or self.child.final_env, 'foreground_processes': self.child.foreground_processes, 'is_self': is_self, @@ -703,6 +719,8 @@ class Window: 'cwd': self.child.current_cwd or self.child.cwd, 'env': self.child.environ, 'cmdline': self.child.cmdline, + 'last_reported_cmdline': self.last_cmd_cmdline, + 'last_cmd_exit_status': self.last_cmd_exit_status, 'margin': self.margin.serialize(), 'user_vars': self.user_vars, 'padding': self.padding.serialize(), @@ -1374,41 +1392,52 @@ class Window: if self.child_title: self.title_stack.append(self.child_title) - def cmd_output_marking(self, is_start: bool) -> None: + def handle_cmd_end(self, exit_status: str = '') -> None: + if self.last_cmd_output_start_time == 0.: + return + self.last_cmd_output_start_time = 0. + try: + self.last_cmd_exit_status = int(exit_status) + except Exception: + self.last_cmd_exit_status = 0 + end_time = monotonic() + last_cmd_output_duration = end_time - self.last_cmd_output_start_time + + self.call_watchers(self.watchers.on_cmd_startstop, { + "is_start": False, "time": end_time, 'cmdline': self.last_cmd_cmdline, 'exit_status': self.last_cmd_exit_status}) + + opts = get_options() + when, duration, action, notify_cmdline = opts.notify_on_cmd_finish + + if last_cmd_output_duration >= duration and when != 'never': + cmd = NotificationCommand() + cmd.title = 'kitty' + s = self.last_cmd_cmdline.replace('\\\n', ' ') + cmd.body = f'Command {s} finished with status: {exit_status}.\nClick to focus.' + cmd.actions = 'focus' + cmd.only_when = OnlyWhen(when) + if action == 'notify': + notify_with_command(cmd, self.id) + elif action == 'bell': + def bell(title: str, body: str, identifier: str) -> None: + self.screen.bell() + notify_with_command(cmd, self.id, notify_implementation=bell) + elif action == 'command': + def run_command(title: str, body: str, identifier: str) -> None: + open_cmd([x.replace('%c', self.last_cmd_cmdline).replace('%s', exit_status) for x in notify_cmdline]) + notify_with_command(cmd, self.id, notify_implementation=run_command) + else: + raise ValueError(f'Unknown action in option `notify_on_cmd_finish`: {action}') + + def cmd_output_marking(self, is_start: Optional[bool], cmdline: str = '') -> 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}) + cmdline = decode_cmdline(cmdline) if cmdline else '' + self.last_cmd_cmdline = cmdline + self.call_watchers(self.watchers.on_cmd_startstop, {"is_start": True, "time": start_time, 'cmdline': cmdline, 'exit_status': 0}) 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() - when, duration, action, cmdline = opts.notify_on_cmd_finish - - if last_cmd_output_duration >= duration and when != 'never': - cmd = NotificationCommand() - cmd.title = 'kitty' - cmd.body = 'Command finished in a background window.\nClick to focus.' - cmd.actions = 'focus' - cmd.only_when = OnlyWhen(when) - if action == 'notify': - notify_with_command(cmd, self.id) - elif action == 'bell': - def bell(title: str, body: str, identifier: str) -> None: - self.screen.bell() - notify_with_command(cmd, self.id, notify_implementation=bell) - elif action == 'command': - def run_command(title: str, body: str, identifier: str) -> None: - open_cmd(cmdline) - notify_with_command(cmd, self.id, notify_implementation=run_command) - else: - raise ValueError(f'Unknown action in option `notify_on_cmd_finish`: {action}') + self.handle_cmd_end(cmdline) # }}} # mouse actions {{{ diff --git a/shell-integration/bash/kitty.bash b/shell-integration/bash/kitty.bash index 84ef94691..e45c96d96 100644 --- a/shell-integration/bash/kitty.bash +++ b/shell-integration/bash/kitty.bash @@ -195,6 +195,22 @@ _ksi_main() { _ksi_prompt[ps0_suffix]+="\[\e[0 q\]" # blinking default cursor fi + _ksi_get_current_command() { + builtin local last_cmd + last_cmd=$(HISTTIMEFORMAT= builtin history 1) + last_cmd="${last_cmd#*[[:digit:]]*[[:space:]]}" # remove leading history number + last_cmd="${last_cmd#"${last_cmd%%[![:space:]]*}"}" # remove remaining leading whitespace + if [[ "${_ksi_prompt[title]}" == "y" ]]; then + builtin printf "\e]2;%s%s\a" "${_ksi_prompt[hostname_prefix]@P}" "${_ksi_prompt[last_cmd]//[[:cntrl:]]}" # remove any control characters + fi + if [[ "${_ksi_prompt[mark]}" == "y" ]]; then + builtin printf "\e]133;C;cmdline=" + for (( i=0; i<${#last_cmd}; i++ )); do builtin printf '%x ' "'${last_cmd:$i:1}"; done + builtin printf "\a" + fi + } + if [[ "${_ksi_prompt[title]}" == "y" || "${_ksi_prompt[mark]}" ]]; then _ksi_prompt[ps0]+='$(_ksi_get_current_command)'; fi + if [[ "${_ksi_prompt[title]}" == "y" ]]; then if [[ -z "$KITTY_PID" ]]; then if [[ -n "$SSH_TTY" || -n "$SSH2_TTY$KITTY_WINDOW_ID" ]]; then @@ -212,22 +228,16 @@ _ksi_main() { # we use suffix here because some distros add title setting to their bashrc files by default _ksi_prompt[ps1_suffix]+="\[\e]2;${_ksi_prompt[hostname_prefix]}\w\a\]" if [[ "$HISTCONTROL" == *"ignoreboth"* ]] || [[ "$HISTCONTROL" == *"ignorespace"* ]]; then - _ksi_debug_print "ignoreboth or ignorespace present in bash HISTCONTROL setting, showing running command in window title will not be robust" + _ksi_debug_print "ignoreboth or ignorespace present in bash HISTCONTROL setting, showing running command will not be robust" fi - _ksi_get_current_command() { - builtin local last_cmd - last_cmd=$(HISTTIMEFORMAT= builtin history 1) - last_cmd="${last_cmd#*[[:digit:]]*[[:space:]]}" # remove leading history number - last_cmd="${last_cmd#"${last_cmd%%[![:space:]]*}"}" # remove remaining leading whitespace - builtin printf "\e]2;%s%s\a" "${_ksi_prompt[hostname_prefix]@P}" "${last_cmd//[[:cntrl:]]}" # remove any control characters - } - _ksi_prompt[ps0_suffix]+='$(_ksi_get_current_command)' fi if [[ "${_ksi_prompt[mark]}" == "y" ]]; then - _ksi_prompt[ps1]+="\[\e]133;A\a\]" + # this can result in multiple D prompt marks or ones that dont + # correspond to a cmd but kitty handles this gracefully, only + # taking into account the first D after a C. + _ksi_prompt[ps1]+="\[\e]133;D;\$?\a\e]133;A\a\]" _ksi_prompt[ps2]+="\[\e]133;A;k=s\a\]" - _ksi_prompt[ps0]+="\[\e]133;C\a\]" fi builtin alias edit-in-kitty="kitten edit-in-kitty" diff --git a/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish b/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish index 090d329af..e052e0cfe 100644 --- a/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish +++ b/shell-integration/fish/vendor_conf.d/kitty-shell-integration.fish @@ -88,7 +88,7 @@ function __ksi_schedule --on-event fish_prompt -d "Setup kitty integration after function __ksi_mark_output_start --on-event fish_preexec set --global __ksi_prompt_state pre-exec - echo -en "\e]133;C\a" + printf '\e]133;C;cmdline_url=%s\a' (string escape --style=url -- "$argv") end function __ksi_mark_output_end --on-event fish_postexec diff --git a/shell-integration/zsh/kitty-integration b/shell-integration/zsh/kitty-integration index c66b6c807..0905cdf1a 100644 --- a/shell-integration/zsh/kitty-integration +++ b/shell-integration/zsh/kitty-integration @@ -215,7 +215,9 @@ _ksi_deferred_init() { # its preexec hook before us, we'll incorrectly mark its output as # belonging to the command (as if the user typed it into zle) rather # than command output. - builtin print -nu $_ksi_fd '\e]133;C\a' + builtin print -nu "$_ksi_fd" '\e]133;C;cmdline=' + for (( i=0; i<${#1}; i++ )); do builtin print -nu "$_ksi_fd" -f '%x ' "'${1:$i:1}"; done + builtin print -nu "$_ksi_fd" '\a' (( _ksi_state = 1 )) } @@ -401,9 +403,9 @@ _ksi_deferred_init() { [[ "$arg" != -* && "$arg" != *=* ]] && builtin break # command found done if [[ "$is_sudoedit" == "y" ]]; then - builtin command sudo "$@"; - else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo "$@"; + else + builtin command sudo TERMINFO="$TERMINFO" "$@"; fi } fi @@ -411,8 +413,8 @@ _ksi_deferred_init() { # Map alt+left/right to move by word if not already mapped. This is expected behavior on macOS and I am tired # of answering questions about it. - [[ $(builtin bindkey "^[[1;3C") == *" undefined-key" ]] && builtin bindkey "^[[1;3C" "forward-word" - [[ $(builtin bindkey "^[[1;3D") == *" undefined-key" ]] && builtin bindkey "^[[1;3D" "backward-word" + [[ $(builtin bindkey "^[[1;3C") == *" undefined-key" ]] && builtin bindkey "^[[1;3C" "forward-word" + [[ $(builtin bindkey "^[[1;3D") == *" undefined-key" ]] && builtin bindkey "^[[1;3D" "backward-word" # Unfunction _ksi_deferred_init to save memory. Don't unfunction # kitty-integration though because decent public functions aren't supposed to