@@ -41,6 +41,8 @@ Detailed list of changes
|
||||
clone local shell and editor configuration on remote machines, and automatic
|
||||
re-use of existing connections to avoid connection setup latency.
|
||||
|
||||
- When pasting URLs at shell prompts automatically quote them. Also allow filtering pasted text and confirm pastes. See :opt:`paste_actions` for details. (:iss:`4873`)
|
||||
|
||||
- macOS: When using Apple's less as the pager for viewing scrollback strip out OSC codes as it cant parse them (:iss:`4788`)
|
||||
|
||||
- diff kitten: Fix incorrect rendering in rare circumstances when scrolling after changing the context size (:iss:`4831`)
|
||||
|
||||
@@ -35,7 +35,7 @@ your machine).
|
||||
# get the kitty window into which to paste answer
|
||||
w = boss.window_id_map.get(target_window_id)
|
||||
if w is not None:
|
||||
w.paste(answer)
|
||||
w.paste_text(answer)
|
||||
|
||||
|
||||
Now in :file:`kitty.conf` add the lines::
|
||||
|
||||
@@ -787,7 +787,7 @@ def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int,
|
||||
if program == '-':
|
||||
w = boss.window_id_map.get(target_window_id)
|
||||
if w is not None:
|
||||
w.paste(joined_text())
|
||||
w.paste_text(joined_text())
|
||||
elif program == '@':
|
||||
set_clipboard_string(joined_text())
|
||||
elif program == '*':
|
||||
|
||||
@@ -591,7 +591,7 @@ def main(args: List[str]) -> Optional[str]:
|
||||
def handle_result(args: List[str], current_char: str, target_window_id: int, boss: BossType) -> None:
|
||||
w = boss.window_id_map.get(target_window_id)
|
||||
if w is not None:
|
||||
w.paste(current_char)
|
||||
w.paste_text(current_char)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1240,7 +1240,7 @@ class Boss:
|
||||
text = ' '.join(map(shlex.quote, urls))
|
||||
else:
|
||||
text = '\n'.join(urls)
|
||||
w.paste(text)
|
||||
w.paste_text(text)
|
||||
|
||||
@ac('win', 'Focus the nth OS window')
|
||||
def nth_os_window(self, num: int = 1) -> None:
|
||||
@@ -1590,7 +1590,7 @@ class Boss:
|
||||
if text:
|
||||
w = self.active_window
|
||||
if w is not None:
|
||||
w.paste(text)
|
||||
w.paste_with_actions(text)
|
||||
|
||||
@ac('cp', 'Paste from the clipboard to the active window')
|
||||
def paste_from_clipboard(self) -> None:
|
||||
|
||||
@@ -447,6 +447,21 @@ system clipboard.
|
||||
'''
|
||||
)
|
||||
|
||||
opt(
|
||||
'paste_actions', 'quote-urls-at-prompt', option_type='paste_actions',
|
||||
long_text='''
|
||||
A comma separated list of actions to take when pasting text into the terminal.
|
||||
Possibilities are:
|
||||
|
||||
* quote-urls-at-prompt: If the text being pasted is a URL and the cursor is at a shell prompt,
|
||||
automatically quote the URL (needs :ref:`shell_integration`).
|
||||
* confirm: Confirm the paste if bracketed paste mode is not active or there is more
|
||||
a large amount of text being pasted.
|
||||
* filter: Run the filter_paste() function from the file :file:`paste-actions.py` in
|
||||
the kitty config directory on the pasted text. The text returned by the function will be actually pasted.
|
||||
'''
|
||||
)
|
||||
|
||||
opt('strip_trailing_spaces', 'never',
|
||||
choices=('always', 'never', 'smart'),
|
||||
long_text='''
|
||||
|
||||
9
kitty/options/parse.py
generated
9
kitty/options/parse.py
generated
@@ -12,9 +12,9 @@ from kitty.options.utils import (
|
||||
deprecated_hide_window_decorations_aliases, deprecated_macos_show_window_title_in_menubar_alias,
|
||||
deprecated_send_text, disable_ligatures, edge_width, env, font_features, hide_window_decorations,
|
||||
macos_option_as_alt, macos_titlebar_color, narrow_symbols, optional_edge_width, parse_map,
|
||||
parse_mouse_map, resize_draw_strategy, scrollback_lines, scrollback_pager_history_size,
|
||||
shell_integration, store_multiple, symbol_map, tab_activity_symbol, tab_bar_edge,
|
||||
tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator,
|
||||
parse_mouse_map, paste_actions, resize_draw_strategy, scrollback_lines,
|
||||
scrollback_pager_history_size, shell_integration, store_multiple, symbol_map, tab_activity_symbol,
|
||||
tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator,
|
||||
tab_title_template, titlebar_color, to_cursor_shape, to_font_size, to_layout_names, to_modifiers,
|
||||
url_prefixes, url_style, visual_window_select_characters, window_border_width, window_size
|
||||
)
|
||||
@@ -1086,6 +1086,9 @@ class Parser:
|
||||
def open_url_with(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
|
||||
ans['open_url_with'] = to_cmdline(val)
|
||||
|
||||
def paste_actions(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
|
||||
ans['paste_actions'] = paste_actions(val)
|
||||
|
||||
def placement_strategy(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
|
||||
val = val.lower()
|
||||
if val not in self.choices_for_placement_strategy:
|
||||
|
||||
2
kitty/options/types.py
generated
2
kitty/options/types.py
generated
@@ -390,6 +390,7 @@ option_names = ( # {{{
|
||||
'mouse_map',
|
||||
'narrow_symbols',
|
||||
'open_url_with',
|
||||
'paste_actions',
|
||||
'placement_strategy',
|
||||
'pointer_shape_when_dragging',
|
||||
'pointer_shape_when_grabbed',
|
||||
@@ -536,6 +537,7 @@ class Options:
|
||||
mark3_foreground: Color = Color(0, 0, 0)
|
||||
mouse_hide_wait: float = 0.0 if is_macos else 3.0
|
||||
open_url_with: typing.List[str] = ['default']
|
||||
paste_actions: typing.FrozenSet[str] = frozenset({'quote-urls-at-prompt'})
|
||||
placement_strategy: choices_for_placement_strategy = 'center'
|
||||
pointer_shape_when_dragging: choices_for_pointer_shape_when_dragging = 'beam'
|
||||
pointer_shape_when_grabbed: choices_for_pointer_shape_when_grabbed = 'arrow'
|
||||
|
||||
@@ -808,6 +808,15 @@ def shell_integration(x: str) -> FrozenSet[str]:
|
||||
return q
|
||||
|
||||
|
||||
def paste_actions(x: str) -> FrozenSet[str]:
|
||||
s = frozenset({'quote-urls-at-prompt', 'confirm', 'filter'})
|
||||
q = frozenset(x.lower().split(','))
|
||||
if not q.issubset(s):
|
||||
log_error(f'Invalid paste actions: {q - s}, ignoring')
|
||||
return q & s or frozenset({'invalid'})
|
||||
return q
|
||||
|
||||
|
||||
def action_alias(val: str) -> Iterable[Tuple[str, str]]:
|
||||
parts = val.split(maxsplit=1)
|
||||
if len(parts) > 1:
|
||||
|
||||
100
kitty/window.py
100
kitty/window.py
@@ -20,7 +20,7 @@ from typing import (
|
||||
from .child import ProcessDesc
|
||||
from .cli_stub import CLIOptions
|
||||
from .config import build_ansi_color_table
|
||||
from .constants import appname, is_macos, wakeup
|
||||
from .constants import appname, is_macos, wakeup, config_dir
|
||||
from .fast_data_types import (
|
||||
BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM,
|
||||
CELL_PROGRAM, CELL_SPECIAL_PROGRAM, CURSOR_BEAM, CURSOR_BLOCK,
|
||||
@@ -42,7 +42,7 @@ from .notify import NotificationCommand, handle_notification_cmd
|
||||
from .options.types import Options
|
||||
from .rgb import to_color
|
||||
from .terminfo import get_capabilities
|
||||
from .types import MouseEvent, WindowGeometry, ac
|
||||
from .types import MouseEvent, WindowGeometry, ac, run_once
|
||||
from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict
|
||||
from .utils import (
|
||||
get_primary_selection, kitty_ansi_sanitizer_pat, load_shaders, log_error,
|
||||
@@ -339,6 +339,23 @@ def setup_colors(screen: Screen, opts: Options) -> None:
|
||||
)
|
||||
|
||||
|
||||
@run_once
|
||||
def load_paste_filter() -> Callable[[str], str]:
|
||||
import runpy
|
||||
import traceback
|
||||
try:
|
||||
m = runpy.run_path(os.path.join(config_dir, 'paste-actions.py'))
|
||||
func: Callable[[str], str] = m['filter_paste']
|
||||
except Exception as e:
|
||||
if not isinstance(e, FileNotFoundError):
|
||||
traceback.print_exc()
|
||||
log_error(f'Failed to load custom draw_tab function with error: {e}')
|
||||
|
||||
def func(text: str) -> str:
|
||||
return text
|
||||
return func
|
||||
|
||||
|
||||
def text_sanitizer(as_ansi: bool, add_wrap_markers: bool) -> Callable[[str], str]:
|
||||
pat = kitty_ansi_sanitizer_pat()
|
||||
ansi, wrap_markers = not as_ansi, not add_wrap_markers
|
||||
@@ -1131,13 +1148,13 @@ class Window:
|
||||
def paste_selection(self) -> None:
|
||||
txt = get_boss().current_primary_selection()
|
||||
if txt:
|
||||
self.paste(txt)
|
||||
self.paste_with_actions(txt)
|
||||
|
||||
@ac('mouse', 'Paste the current primary selection or the clipboard if no selection is present')
|
||||
def paste_selection_or_clipboard(self) -> None:
|
||||
txt = get_boss().current_primary_selection_or_clipboard()
|
||||
if txt:
|
||||
self.paste(txt)
|
||||
self.paste_with_actions(txt)
|
||||
|
||||
@ac('mouse', '''
|
||||
Select clicked command output
|
||||
@@ -1236,6 +1253,58 @@ class Window:
|
||||
path = resolve_custom_file(path) if path else ''
|
||||
set_window_logo(self.os_window_id, self.tab_id, self.id, path, position or '', alpha)
|
||||
|
||||
def paste_with_actions(self, text: str) -> None:
|
||||
if self.destroyed or not text:
|
||||
return
|
||||
opts = get_options()
|
||||
if 'filter' in opts.paste_actions:
|
||||
text = load_paste_filter()(text)
|
||||
if not text:
|
||||
return
|
||||
if 'quote-urls-at-prompt' in opts.paste_actions and self.at_prompt:
|
||||
prefixes = '|'.join(opts.url_prefixes)
|
||||
if re.match(f'{prefixes}:', text) is not None:
|
||||
import shlex
|
||||
text = shlex.quote(text)
|
||||
btext = text.encode('utf-8')
|
||||
if 'confirm' in opts.paste_actions:
|
||||
msg = ''
|
||||
limit = 16 * 1024
|
||||
if not self.screen.in_bracketed_paste_mode:
|
||||
msg = _('Pasting text into shells that do not support bracketed paste can be dangerous.')
|
||||
elif len(btext) > limit:
|
||||
msg = _('Pasting very large amounts of text ({} bytes) can be slow.').format(len(btext))
|
||||
if msg:
|
||||
get_boss().confirm(msg + _(' Are you sure?'), partial(self.handle_paste_confirmation, btext), window=self)
|
||||
return
|
||||
self.paste_text(btext)
|
||||
|
||||
def handle_paste_confirmation(self, btext: bytes, confirmed: bool) -> None:
|
||||
if confirmed:
|
||||
self.paste_text(btext)
|
||||
|
||||
def paste_bytes(self, text: Union[str, bytes]) -> None:
|
||||
# paste raw bytes without any processing
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
self.screen.paste_bytes(text)
|
||||
|
||||
def paste_text(self, text: Union[str, bytes]) -> None:
|
||||
if text and not self.destroyed:
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
if self.screen.in_bracketed_paste_mode:
|
||||
while True:
|
||||
new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'')
|
||||
if len(text) == len(new_text):
|
||||
break
|
||||
text = new_text
|
||||
else:
|
||||
# Workaround for broken editors like nano that cannot handle
|
||||
# newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994
|
||||
text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r')
|
||||
self.screen.paste(text)
|
||||
|
||||
# actions {{{
|
||||
|
||||
@ac('cp', 'Show scrollback in a pager like less')
|
||||
@@ -1275,28 +1344,9 @@ class Window:
|
||||
def show_last_visited_command_output(self) -> None:
|
||||
self.show_cmd_output(CommandOutput.last_visited, 'Last visited command output')
|
||||
|
||||
def paste_bytes(self, text: Union[str, bytes]) -> None:
|
||||
# paste raw bytes without any processing
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
self.screen.paste_bytes(text)
|
||||
|
||||
@ac('cp', 'Paste the specified text into the current window')
|
||||
def paste(self, text: Union[str, bytes]) -> None:
|
||||
if text and not self.destroyed:
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
if self.screen.in_bracketed_paste_mode:
|
||||
while True:
|
||||
new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'')
|
||||
if len(text) == len(new_text):
|
||||
break
|
||||
text = new_text
|
||||
else:
|
||||
# Workaround for broken editors like nano that cannot handle
|
||||
# newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994
|
||||
text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r')
|
||||
self.screen.paste(text)
|
||||
def paste(self, text: str) -> None:
|
||||
self.paste_with_actions(text)
|
||||
|
||||
@ac('cp', 'Copy the selected text from the active window to the clipboard')
|
||||
def copy_to_clipboard(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user