notify_on_cmd_finish: Show the actual command that was finished

Fixes #7420
This commit is contained in:
Kovid Goyal 2024-05-09 09:49:26 +05:30
parent c50e38a080
commit 0d68a21be5
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
9 changed files with 123 additions and 56 deletions

View file

@ -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

View file

@ -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

View file

@ -433,6 +433,10 @@ Just before running a command/program, send the escape code::
<OSC>133;C<ST>
Optionally, when a command is finished its "exit status" can be reported as::
<OSC>133;D;exit status as base 10 integer<ST>
Here ``<OSC>`` is the bytes ``0x1b 0x5d`` and ``<ST>`` 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 ``<OSC>133;C`` as::
<OSC>133;C;cmdline=cmdline as space separated hex encoded text<ST>
or
<OSC>133;C;cmdline_url=cmdline as UTF-8 URL escaped text<ST>
Here, *space separated hex encoded text* means every unicode codepoint of the
command line is encoded as 2-8 hex digits separated by spaces.

View file

@ -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
'''
)

View file

@ -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;
}
}
}

View file

@ -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 {{{

View file

@ -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"

View file

@ -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

View file

@ -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