#!/usr/bin/env python3 # License: GPL v3 Copyright: 2018, Kovid Goyal import os import re import string import sys from functools import lru_cache from gettext import gettext as _ from itertools import repeat from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, Optional, Pattern, Sequence, Set, Tuple, Type, cast from kitty.cli import parse_args from kitty.cli_stub import HintsCLIOptions from kitty.clipboard import set_clipboard_string, set_primary_selection from kitty.constants import website_url from kitty.fast_data_types import get_options, wcswidth from kitty.key_encoding import KeyEvent from kitty.typing import BossType, KittyCommonOpts from kitty.utils import ScreenSize, kitty_ansi_sanitizer_pat, resolve_custom_file, screen_size_function from ..tui.handler import Handler, result_handler from ..tui.loop import Loop from ..tui.operations import faint, styled from ..tui.utils import report_error, report_unhandled_error @lru_cache() def kitty_common_opts() -> KittyCommonOpts: import json v = os.environ.get('KITTY_COMMON_OPTS') if v: return cast(KittyCommonOpts, json.loads(v)) from kitty.config import common_opts_as_dict return common_opts_as_dict() DEFAULT_HINT_ALPHABET = string.digits + string.ascii_lowercase DEFAULT_REGEX = r'(?m)^\s*(.+)\s*$' FILE_EXTENSION = r'\.(?:[a-zA-Z0-9]{2,7}|[ahcmo])(?!\.)' PATH_REGEX = fr'(?:\S*?/[\r\S]+)|(?:\S[\r\S]*{FILE_EXTENSION})\b' DEFAULT_LINENUM_REGEX = fr'(?P{PATH_REGEX}):(?P\d+)' class Mark: __slots__ = ('index', 'start', 'end', 'text', 'is_hyperlink', 'group_id', 'groupdict') def __init__( self, index: int, start: int, end: int, text: str, groupdict: Any, is_hyperlink: bool = False, group_id: Optional[str] = None ): self.index, self.start, self.end = index, start, end self.text = text self.groupdict = groupdict self.is_hyperlink = is_hyperlink self.group_id = group_id def __repr__(self) -> str: return (f'Mark(index={self.index!r}, start={self.start!r}, end={self.end!r},' f' text={self.text!r}, groupdict={self.groupdict!r}, is_hyperlink={self.is_hyperlink!r}, group_id={self.group_id!r})') @lru_cache(maxsize=2048) def encode_hint(num: int, alphabet: str) -> str: res = '' d = len(alphabet) while not res or num > 0: num, i = divmod(num, d) res = alphabet[i] + res return res def decode_hint(x: str, alphabet: str = DEFAULT_HINT_ALPHABET) -> int: base = len(alphabet) index_map = {c: i for i, c in enumerate(alphabet)} i = 0 for char in x: i = i * base + index_map[char] return i def highlight_mark(m: Mark, text: str, current_input: str, alphabet: str, colors: Dict[str, str]) -> str: hint = encode_hint(m.index, alphabet) if current_input and not hint.startswith(current_input): return faint(text) hint = hint[len(current_input):] or ' ' text = text[len(hint):] return styled( hint, fg=colors['foreground'], bg=colors['background'], bold=True ) + styled( text, fg=colors['text'], fg_intense=True, bold=True ) def debug(*a: Any, **kw: Any) -> None: from ..tui.loop import debug as d d(*a, **kw) def render(text: str, current_input: str, all_marks: Sequence[Mark], ignore_mark_indices: Set[int], alphabet: str, colors: Dict[str, str]) -> str: for mark in reversed(all_marks): if mark.index in ignore_mark_indices: continue mtext = highlight_mark(mark, text[mark.start:mark.end], current_input, alphabet, colors) text = text[:mark.start] + mtext + text[mark.end:] text = text.replace('\0', '') return re.sub('[\r\n]', '\r\n', text).rstrip() class Hints(Handler): use_alternate_screen = False # disabled to avoid screen being blanked at exit causing flicker overlay_ready_report_needed = True def __init__(self, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], args: HintsCLIOptions): self.text, self.index_map = text, index_map self.alphabet = args.alphabet or DEFAULT_HINT_ALPHABET self.colors = {'foreground': args.hints_foreground_color, 'background': args.hints_background_color, 'text': args.hints_text_color} self.all_marks = all_marks self.ignore_mark_indices: Set[int] = set() self.args = args self.window_title = args.window_title or (_('Choose URL') if args.type == 'url' else _('Choose text')) self.multiple = args.multiple self.match_suffix = self.get_match_suffix(args) self.chosen: List[Mark] = [] self.reset() @property def text_matches(self) -> List[str]: return [m.text + self.match_suffix for m in self.chosen] @property def groupdicts(self) -> List[Any]: return [m.groupdict for m in self.chosen] def get_match_suffix(self, args: HintsCLIOptions) -> str: if args.add_trailing_space == 'always': return ' ' if args.add_trailing_space == 'never': return '' return ' ' if args.multiple else '' def reset(self) -> None: self.current_input = '' self.current_text: Optional[str] = None def init_terminal_state(self) -> None: self.cmd.set_cursor_visible(False) self.cmd.set_window_title(self.window_title) self.cmd.set_line_wrapping(False) def initialize(self) -> None: self.init_terminal_state() self.draw_screen() def on_text(self, text: str, in_bracketed_paste: bool = False) -> None: changed = False for c in text: if c in self.alphabet: self.current_input += c changed = True if changed: matches = [ m for idx, m in self.index_map.items() if encode_hint(idx, self.alphabet).startswith(self.current_input) ] if len(matches) == 1: self.chosen.append(matches[0]) if self.multiple: self.ignore_mark_indices.add(matches[0].index) self.reset() else: self.quit_loop(0) return self.current_text = None self.draw_screen() def on_key(self, key_event: KeyEvent) -> None: if key_event.matches('backspace'): self.current_input = self.current_input[:-1] self.current_text = None self.draw_screen() elif (key_event.matches('enter') or key_event.matches('space')) and self.current_input: try: idx = decode_hint(self.current_input, self.alphabet) self.chosen.append(self.index_map[idx]) self.ignore_mark_indices.add(idx) except Exception: self.current_input = '' self.current_text = None self.draw_screen() else: if self.multiple: self.reset() self.draw_screen() else: self.quit_loop(0) elif key_event.matches('esc'): self.quit_loop(0 if self.multiple else 1) def on_interrupt(self) -> None: self.quit_loop(1) def on_eot(self) -> None: self.quit_loop(1) def on_resize(self, new_size: ScreenSize) -> None: self.draw_screen() def draw_screen(self) -> None: if self.current_text is None: self.current_text = render(self.text, self.current_input, self.all_marks, self.ignore_mark_indices, self.alphabet, self.colors) self.cmd.clear_screen() self.write(self.current_text) def regex_finditer(pat: 'Pattern[str]', minimum_match_length: int, text: str) -> Iterator[Tuple[int, int, 're.Match[str]']]: has_named_groups = bool(pat.groupindex) for m in pat.finditer(text): s, e = m.span(0 if has_named_groups else pat.groups) while e > s + 1 and text[e-1] == '\0': e -= 1 if e - s >= minimum_match_length: yield s, e, m closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'", "“": "”", "‘": "’"} opening_brackets = ''.join(closing_bracket_map) PostprocessorFunc = Callable[[str, int, int], Tuple[int, int]] postprocessor_map: Dict[str, PostprocessorFunc] = {} def postprocessor(func: PostprocessorFunc) -> PostprocessorFunc: postprocessor_map[func.__name__] = func return func class InvalidMatch(Exception): """Raised when a match turns out to be invalid.""" pass @postprocessor def url(text: str, s: int, e: int) -> Tuple[int, int]: if s > 4 and text[s - 5:s] == 'link:': # asciidoc URLs url = text[s:e] idx = url.rfind('[') if idx > -1: e -= len(url) - idx while text[e - 1] in '.,?!' and e > 1: # remove trailing punctuation e -= 1 # truncate url at closing bracket/quote if s > 0 and e <= len(text) and text[s-1] in opening_brackets: q = closing_bracket_map[text[s-1]] idx = text.find(q, s) if idx > s: e = idx # Restructured Text URLs if e > 3 and text[e-2:e] == '`_': e -= 2 return s, e @postprocessor def brackets(text: str, s: int, e: int) -> Tuple[int, int]: # Remove matching brackets if s < e <= len(text): before = text[s] if before in '({[<': q = closing_bracket_map[before] if text[e-1] == q: s += 1 e -= 1 elif text[e:e+1] == q: s += 1 return s, e @postprocessor def quotes(text: str, s: int, e: int) -> Tuple[int, int]: # Remove matching quotes if s < e <= len(text): before = text[s] if before in '\'"“‘': q = closing_bracket_map[before] if text[e-1] == q: s += 1 e -= 1 elif text[e:e+1] == q: s += 1 return s, e @postprocessor def ip(text: str, s: int, e: int) -> Tuple[int, int]: from ipaddress import ip_address # Check validity of IPs (or raise InvalidMatch) ip = text[s:e] try: ip_address(ip) except Exception: raise InvalidMatch("Invalid IP") return s, e def mark(pattern: str, post_processors: Iterable[PostprocessorFunc], text: str, args: HintsCLIOptions) -> Iterator[Mark]: pat = re.compile(pattern) sanitize_pat = re.compile('[\r\n\0]') for idx, (s, e, match_object) in enumerate(regex_finditer(pat, args.minimum_match_length, text)): try: for func in post_processors: s, e = func(text, s, e) except InvalidMatch: continue groupdict = match_object.groupdict() for group_name in groupdict: group_idx = pat.groupindex[group_name] gs, ge = match_object.span(group_idx) gs, ge = max(gs, s), min(ge, e) groupdict[group_name] = sanitize_pat.sub('', text[gs:ge]) mark_text = sanitize_pat.sub('', text[s:e]) yield Mark(idx, s, e, mark_text, groupdict) def run_loop(args: HintsCLIOptions, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], extra_cli_args: Sequence[str] = ()) -> Dict[str, Any]: loop = Loop() handler = Hints(text, all_marks, index_map, args) loop.loop(handler) if handler.chosen and loop.return_code == 0: return { 'match': handler.text_matches, 'programs': args.program, 'multiple_joiner': args.multiple_joiner, 'customize_processing': args.customize_processing, 'type': args.type, 'groupdicts': handler.groupdicts, 'extra_cli_args': extra_cli_args, 'linenum_action': args.linenum_action, 'cwd': os.getcwd(), } raise SystemExit(loop.return_code) def escape(chars: str) -> str: return chars.replace('\\', '\\\\').replace('-', r'\-').replace(']', r'\]') def functions_for(args: HintsCLIOptions) -> Tuple[str, List[PostprocessorFunc]]: post_processors = [] if args.type == 'url': if args.url_prefixes == 'default': url_prefixes = kitty_common_opts()['url_prefixes'] else: url_prefixes = tuple(args.url_prefixes.split(',')) from .url_regex import url_delimiters pattern = '(?:{})://[^{}]{{3,}}'.format( '|'.join(url_prefixes), url_delimiters ) post_processors.append(url) elif args.type == 'path': pattern = PATH_REGEX post_processors.extend((brackets, quotes)) elif args.type == 'line': pattern = '(?m)^\\s*(.+)[\\s\0]*$' elif args.type == 'hash': pattern = '[0-9a-f][0-9a-f\r]{6,127}' elif args.type == 'ip': pattern = ( # # IPv4 with no validation r"((?:\d{1,3}\.){3}\d{1,3}" r"|" # # IPv6 with no validation r"(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{1,4})" ) post_processors.append(ip) elif args.type == 'word': chars = args.word_characters if chars is None: chars = kitty_common_opts()['select_by_word_characters'] pattern = fr'(?u)[{escape(chars)}\w]{{{args.minimum_match_length},}}' post_processors.extend((brackets, quotes)) else: pattern = args.regex return pattern, post_processors def convert_text(text: str, cols: int) -> str: lines: List[str] = [] empty_line = '\0' * cols + '\n' for full_line in text.split('\n'): if full_line: if not full_line.rstrip('\r'): # empty lines lines.extend(repeat(empty_line, len(full_line))) continue appended = False for line in full_line.split('\r'): if line: line_sz = wcswidth(line) if line_sz < cols: line += '\0' * (cols - line_sz) lines.append(line) lines.append('\r') appended = True if appended: lines[-1] = '\n' rstripped = re.sub('[\r\n]+$', '', ''.join(lines)) return rstripped def parse_input(text: str) -> str: try: cols = int(os.environ['OVERLAID_WINDOW_COLS']) except KeyError: cols = screen_size_function()().cols return convert_text(text, cols) def linenum_marks(text: str, args: HintsCLIOptions, Mark: Type[Mark], extra_cli_args: Sequence[str], *a: Any) -> Generator[Mark, None, None]: regex = args.regex if regex == DEFAULT_REGEX: regex = DEFAULT_LINENUM_REGEX yield from mark(regex, [brackets, quotes], text, args) def load_custom_processor(customize_processing: str) -> Any: if customize_processing.startswith('::import::'): import importlib m = importlib.import_module(customize_processing[len('::import::'):]) return {k: getattr(m, k) for k in dir(m)} if customize_processing == '::linenum::': return {'mark': linenum_marks, 'handle_result': linenum_handle_result} custom_path = resolve_custom_file(customize_processing) import runpy return runpy.run_path(custom_path, run_name='__main__') def process_escape_codes(text: str) -> Tuple[str, Tuple[Mark, ...]]: hyperlinks: List[Mark] = [] removed_size = idx = 0 active_hyperlink_url: Optional[str] = None active_hyperlink_id: Optional[str] = None active_hyperlink_start_offset = 0 def add_hyperlink(end: int) -> None: nonlocal idx, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset assert active_hyperlink_url is not None hyperlinks.append(Mark( idx, active_hyperlink_start_offset, end, active_hyperlink_url, groupdict={}, is_hyperlink=True, group_id=active_hyperlink_id )) active_hyperlink_url = active_hyperlink_id = None active_hyperlink_start_offset = 0 idx += 1 def process_hyperlink(m: 're.Match[str]') -> str: nonlocal removed_size, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset raw = m.group() if not raw.startswith('\x1b]8'): removed_size += len(raw) return '' start = m.start() - removed_size removed_size += len(raw) if active_hyperlink_url is not None: add_hyperlink(start) raw = raw[4:-2] parts = raw.split(';', 1) if len(parts) == 2 and parts[1]: active_hyperlink_url = parts[1] active_hyperlink_start_offset = start if parts[0]: for entry in parts[0].split(':'): if entry.startswith('id=') and len(entry) > 3: active_hyperlink_id = entry[3:] break return '' text = kitty_ansi_sanitizer_pat().sub(process_hyperlink, text) if active_hyperlink_url is not None: add_hyperlink(len(text)) return text, tuple(hyperlinks) def run(args: HintsCLIOptions, text: str, extra_cli_args: Sequence[str] = ()) -> Optional[Dict[str, Any]]: try: text = parse_input(text) text, hyperlinks = process_escape_codes(text) pattern, post_processors = functions_for(args) if args.type == 'linenum': args.customize_processing = '::linenum::' if args.type == 'hyperlink': all_marks = hyperlinks elif args.customize_processing: m = load_custom_processor(args.customize_processing) if 'mark' in m: all_marks = tuple(m['mark'](text, args, Mark, extra_cli_args)) else: all_marks = tuple(mark(pattern, post_processors, text, args)) else: all_marks = tuple(mark(pattern, post_processors, text, args)) if not all_marks: none_of = {'url': 'URLs', 'hyperlink': 'hyperlinks'}.get(args.type, 'matches') report_error(_('No {} found.').format(none_of)) return None largest_index = all_marks[-1].index offset = max(0, args.hints_offset) for m in all_marks: if args.ascending: m.index += offset else: m.index = largest_index - m.index + offset index_map = {m.index: m for m in all_marks} except Exception: report_unhandled_error() return run_loop(args, text, all_marks, index_map, extra_cli_args) # CLI {{{ OPTIONS = r''' --program type=list What program to use to open matched text. Defaults to the default open program for the operating system. Various special values are supported: :code:`-` paste the match into the terminal window. :code:`@` copy the match to the clipboard :code:`*` copy the match to the primary selection (on systems that support primary selections) :code:`default` run the default open program. :code:`launch` run :doc:`/launch` to open the program in a new kitty tab, window, overlay, etc. For example:: --program "launch --type=tab vim" Can be specified multiple times to run multiple programs. --type default=url choices=url,regex,path,line,hash,word,linenum,hyperlink,ip The type of text to search for. A value of :code:`linenum` is special, it looks for error messages using the pattern specified with :option:`--regex`, which must have the named groups: :code:`path` and :code:`line`. If not specified, will look for :code:`path:line`. The :option:`--linenum-action` option controls where to display the selected error message, other options are ignored. --regex default={default_regex} The regular expression to use when option :option:`--type` is set to :code:`regex`, in python syntax. If you specify a numbered group in the regular expression, only the group will be matched. This allow you to match text ignoring a prefix/suffix, as needed. The default expression matches lines. To match text over multiple lines, you should prefix the regular expression with :code:`(?ms)`, which turns on MULTILINE and DOTALL modes for the regex engine. If you specify named groups and a :option:`--program`, then the program will be passed arguments corresponding to each named group of the form :code:`key=value`. --linenum-action default=self type=choice choices=self,window,tab,os_window,background Where to perform the action on matched errors. :code:`self` means the current window, :code:`window` a new kitty window, :code:`tab` a new tab, :code:`os_window` a new OS window and :code:`background` run in the background. The actual action is whatever arguments are provided to the kitten, for example: :code:`kitty +kitten hints --type=linenum --linenum-action=tab vim +{line} {path}` will open the matched path at the matched line number in vim in a new kitty tab. Note that in order to use :option:`--program` to copy or paste text, you need to use the special value :code:`self`. --url-prefixes default=default Comma separated list of recognized URL prefixes. Defaults to the list of prefixes defined by the :opt:`url_prefixes` option in :file:`kitty.conf`. --word-characters Characters to consider as part of a word. In addition, all characters marked as alphanumeric in the Unicode database will be considered as word characters. Defaults to the :opt:`select_by_word_characters` option from :file:`kitty.conf`. --minimum-match-length default=3 type=int The minimum number of characters to consider a match. --multiple type=bool-set Select multiple matches and perform the action on all of them together at the end. In this mode, press :kbd:`Esc` to finish selecting. --multiple-joiner default=auto String for joining multiple selections when copying to the clipboard or inserting into the terminal. The special values are: :code:`space` - a space character, :code:`newline` - a newline, :code:`empty` - an empty joiner, :code:`json` - a JSON serialized list, :code:`auto` - an automatic choice, based on the type of text being selected. In addition, integers are interpreted as zero-based indices into the list of selections. You can use :code:`0` for the first selection and :code:`-1` for the last. --add-trailing-space default=auto choices=auto,always,never Add trailing space after matched text. Defaults to :code:`auto`, which adds the space when used together with :option:`--multiple`. --hints-offset default=1 type=int The offset (from zero) at which to start hint numbering. Note that only numbers greater than or equal to zero are respected. --alphabet The list of characters to use for hints. The default is to use numbers and lowercase English alphabets. Specify your preference as a string of characters. Note that you need to specify the :option:`--hints-offset` as zero to use the first character to highlight the first match, otherwise it will start with the second character by default. --ascending type=bool-set Make the hints increase from top to bottom, instead of decreasing from top to bottom. --hints-foreground-color default=black type=str The foreground color for hints. --hints-background-color default=green type=str The background color for hints. --hints-text-color default=gray type=str The foreground color for text pointed to by the hints. --customize-processing Name of a python file in the kitty config directory which will be imported to provide custom implementations for pattern finding and performing actions on selected matches. You can also specify absolute paths to load the script from elsewhere. See {hints_url} for details. --window-title The title for the hints window, default title is based on the type of text being hinted. '''.format( default_regex=DEFAULT_REGEX, line='{{line}}', path='{{path}}', hints_url=website_url('kittens/hints'), ).format help_text = 'Select text from the screen using the keyboard. Defaults to searching for URLs.' usage = '' def parse_hints_args(args: List[str]) -> Tuple[HintsCLIOptions, List[str]]: return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints', result_class=HintsCLIOptions) def main(args: List[str]) -> Optional[Dict[str, Any]]: text = '' if sys.stdin.isatty(): if '--help' not in args and '-h' not in args: report_unhandled_error('You must pass the text to be hinted on STDIN') else: text = sys.stdin.buffer.read().decode('utf-8') sys.stdin = open(os.ctermid()) try: opts, items = parse_hints_args(args[1:]) except SystemExit as e: if e.code != 0: report_unhandled_error(e.args[0]) return None if items and not (opts.customize_processing or opts.type == 'linenum'): report_unhandled_error('Extra command line arguments present: {}'.format(' '.join(items))) try: return run(opts, text, items) except Exception: report_unhandled_error() return None def linenum_process_result(data: Dict[str, Any]) -> Tuple[str, int]: lnum_pat = re.compile(r'(:\d+)$') for match, g in zip(data['match'], data['groupdicts']): path, line = g['path'], g['line'] if path and line: m = lnum_pat.search(path) if m is not None: line = m.group(1)[1:] path = path.rpartition(':')[0] return os.path.expanduser(path), int(line) return '', -1 def linenum_handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, boss: BossType, extra_cli_args: Sequence[str], *a: Any) -> None: path, line = linenum_process_result(data) if not path: return cmd = [x.format(path=path, line=line) for x in extra_cli_args or ('vim', '+{line}', '{path}')] w = boss.window_id_map.get(target_window_id) action = data['linenum_action'] if action == 'self': if w is not None: is_copy_action = cmd[0] in ('-', '@', '*') if is_copy_action: text = ' '.join(cmd[1:]) if cmd[0] == '-': w.paste_bytes(text) elif cmd[0] == '@': set_clipboard_string(text) elif cmd[0] == '*': set_primary_selection(text) else: import shlex text = ' '.join(shlex.quote(arg) for arg in cmd) w.paste_bytes(f'{text}\r') elif action == 'background': import subprocess subprocess.Popen(cmd, cwd=data['cwd']) else: getattr(boss, { 'window': 'new_window_with_cwd', 'tab': 'new_tab_with_cwd', 'os_window': 'new_os_window_with_cwd' }[action])(*cmd) @result_handler(type_of_input='screen-ansi', has_ready_notification=Hints.overlay_ready_report_needed) def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, boss: BossType) -> None: if data['customize_processing']: m = load_custom_processor(data['customize_processing']) if 'handle_result' in m: m['handle_result'](args, data, target_window_id, boss, data['extra_cli_args']) return None programs = data['programs'] or ('default',) matches: List[str] = [] groupdicts = [] for m, g in zip(data['match'], data['groupdicts']): if m: matches.append(m) groupdicts.append(g) joiner = data['multiple_joiner'] try: is_int: Optional[int] = int(joiner) except Exception: is_int = None text_type = data['type'] @lru_cache() def joined_text() -> str: if is_int is not None: try: return matches[is_int] except IndexError: return matches[-1] if joiner == 'json': import json return json.dumps(matches, ensure_ascii=False, indent='\t') if joiner == 'auto': q = '\n\r' if text_type in ('line', 'url') else ' ' else: q = {'newline': '\n\r', 'space': ' '}.get(joiner, '') return q.join(matches) for program in programs: if program == '-': w = boss.window_id_map.get(target_window_id) if w is not None: w.paste_text(joined_text()) elif program == '@': set_clipboard_string(joined_text()) elif program == '*': set_primary_selection(joined_text()) else: from kitty.conf.utils import to_cmdline cwd = data['cwd'] program = get_options().open_url_with if program == 'default' else program if text_type == 'hyperlink': w = boss.window_id_map.get(target_window_id) for m in matches: if w is not None: w.open_url(m, hyperlink_id=1, cwd=cwd) else: launch_args = [] if isinstance(program, str) and program.startswith('launch '): launch_args = to_cmdline(program) launch_args.insert(1, '--cwd=' + cwd) for m, groupdict in zip(matches, groupdicts): if groupdict: m = [] for k, v in groupdict.items(): m.append('{}={}'.format(k, v or '')) if launch_args: w = boss.window_id_map.get(target_window_id) boss.call_remote_control(active_window=w, args=tuple(launch_args + ([m] if isinstance(m, str) else m))) else: boss.open_url(m, program, cwd=cwd) if __name__ == '__main__': # Run with kitty +kitten hints ans = main(sys.argv) if ans: print(ans) elif __name__ == '__doc__': cd = sys.cli_docs # type: ignore cd['usage'] = usage cd['options'] = OPTIONS cd['help_text'] = help_text # }}}