Document the kitty remote control protocol

Fixes #1646
This commit is contained in:
Kovid Goyal 2019-06-12 13:12:53 +05:30
parent 15e8f6ad8a
commit 658be9405f
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
8 changed files with 149 additions and 9 deletions

View File

@ -10,6 +10,8 @@ To update |kitty|, :doc:`follow the instructions <binary>`.
- Fix an out of bounds read causing a crash when selecting text with the mouse - Fix an out of bounds read causing a crash when selecting text with the mouse
in the alternate screen mode (:iss:`1578`) in the alternate screen mode (:iss:`1578`)
- Document the kitty remote control protocol (:iss:`1646`)
0.14.2 [2019-06-09] 0.14.2 [2019-06-09]
--------------------- ---------------------

View File

@ -276,6 +276,48 @@ def write_cli_docs(all_kitten_names):
# }}} # }}}
def write_remote_control_protocol_docs(): # {{{
from kitty.cmds import cmap
field_pat = re.compile(r'\s*([a-zA-Z0-9_+]+)\s*:\s*(.+)')
def format_cmd(p, name, cmd):
p(name)
p('-' * 80)
lines = cmd.__doc__.strip().splitlines()
fields = []
for line in lines:
m = field_pat.match(line)
if m is None:
p(line)
else:
fields.append((m.group(1), m.group(2)))
if fields:
p('\nFields are:\n')
for (name, desc) in fields:
if '+' in name:
title = name.replace('+', ' (required)')
else:
title = name
defval = cmd.get_default(name.replace('-', '_'), cmd)
if defval is not cmd:
title = f'{title} (default: {defval})'
else:
title = f'{title} (optional)'
p(f':code:`{title}`')
p(' ', desc), p()
p(), p()
with open(f'generated/rc.rst', 'w') as f:
p = partial(print, file=f)
for name in sorted(cmap):
cmd = cmap[name]
if not cmd.__doc__:
continue
name = name.replace('_', '-')
format_cmd(p, name, cmd)
# }}}
# config file docs {{{ # config file docs {{{
class ConfLexer(RegexLexer): class ConfLexer(RegexLexer):
@ -537,6 +579,7 @@ def setup(app):
from kittens.runner import all_kitten_names from kittens.runner import all_kitten_names
all_kitten_names = all_kitten_names() all_kitten_names = all_kitten_names()
write_cli_docs(all_kitten_names) write_cli_docs(all_kitten_names)
write_remote_control_protocol_docs()
write_conf_docs(app, all_kitten_names) write_conf_docs(app, all_kitten_names)
app.add_lexer('session', SessionLexer()) app.add_lexer('session', SessionLexer())
app.add_role('link', link_role) app.add_role('link', link_role)

View File

@ -486,3 +486,4 @@ See :doc:`changelog`.
* *
kittens/* kittens/*
generated/rc

29
docs/rc_protocol.rst Normal file
View File

@ -0,0 +1,29 @@
Documentation for the kitty remote control protocol
======================================================
The kitty remote control protocol is a simple protocol that involves sending
data to kitty in the form of JSON. Any individual command ot kitty has the
form::
<ESC>P@kitty-cmd<JSON object><ESC>\
Where ``<ESC>`` is the byte ``0x1b``. The JSON object has the form::
{
'cmd': "command name",
'version': "kitty version",
'no_response': Optional Boolean,
'payload': <Optional JSON object>,
}
The ``version`` above is a string of the form :code:`0.14.2`. If you are developing a
standalone client, use the kitty version that you are developing against. Using
a version greater than the version of the kitty instance you are talking to,
will cause a failure.
Set ``no_response`` to True if you dont want a response from kitty.
The optional payload is a JSON object that is specific to the actual command being sent.
The fields in the object for every command are documented below.
.. include:: generated/rc.rst

View File

@ -134,5 +134,10 @@ still write to the pipes of any other program on the same computer and
therefore can control |kitty|. It can, however, be useful to block programs therefore can control |kitty|. It can, however, be useful to block programs
running on other computers (for example, over ssh) or as other users. running on other computers (for example, over ssh) or as other users.
Documentation for the remote control protocol
-----------------------------------------------
If you wish to develop your own client to talk to |kitty|, you
can use the :doc:`rc_protocol`.
.. include:: generated/cli-kitty-at.rst .. include:: generated/cli-kitty-at.rst

View File

@ -212,6 +212,14 @@ def wrap(text, limit=80):
return reversed(lines) return reversed(lines)
def get_defaults_from_seq(seq):
ans = {}
for opt in seq:
if not isinstance(opt, str):
ans[opt['dest']] = defval_for_opt(opt)
return ans
default_msg = ('''\ default_msg = ('''\
Run the :italic:`{appname}` terminal emulator. You can also specify the :italic:`program` Run the :italic:`{appname}` terminal emulator. You can also specify the :italic:`program`
to run inside :italic:`{appname}` as normal arguments following the :italic:`options`. to run inside :italic:`{appname}` as normal arguments following the :italic:`options`.

View File

@ -7,7 +7,7 @@ import os
import sys import sys
from contextlib import suppress from contextlib import suppress
from .cli import parse_args from .cli import parse_args, parse_option_spec, get_defaults_from_seq
from .config import parse_config, parse_send_text_bytes from .config import parse_config, parse_send_text_bytes
from .constants import appname from .constants import appname
from .fast_data_types import focus_os_window from .fast_data_types import focus_os_window
@ -36,7 +36,33 @@ class UnknownLayout(ValueError):
cmap = {} cmap = {}
def cmd(short_desc, desc=None, options_spec=None, no_response=False, argspec='...', string_return_is_error=False, args_count=None): def cmd(
short_desc,
desc=None,
options_spec=None,
no_response=False,
argspec='...',
string_return_is_error=False,
args_count=None,
):
if options_spec:
defaults = None
def get_defaut_value(name, missing=None):
nonlocal defaults
if defaults is None:
defaults = get_defaults_from_seq(parse_option_spec(options_spec)[0])
return defaults.get(name, missing)
else:
def get_defaut_value(name, missing=None):
return missing
def payload_get(payload, key, opt_name=None):
ans = payload.get(key, payload_get)
if ans is not payload_get:
return ans
return get_defaut_value(opt_name or key)
def w(func): def w(func):
func.short_desc = short_desc func.short_desc = short_desc
@ -49,6 +75,8 @@ def cmd(short_desc, desc=None, options_spec=None, no_response=False, argspec='..
func.no_response = no_response func.no_response = no_response
func.string_return_is_error = string_return_is_error func.string_return_is_error = string_return_is_error
func.args_count = 0 if not argspec else args_count func.args_count = 0 if not argspec else args_count
func.get_default = get_defaut_value
func.payload_get = payload_get
cmap[func.name] = func cmap[func.name] = func
return func return func
return w return w
@ -109,6 +137,9 @@ def windows_for_payload(boss, window, payload):
argspec='' argspec=''
) )
def cmd_ls(global_opts, opts, args): def cmd_ls(global_opts, opts, args):
'''
No payload
'''
pass pass
@ -135,6 +166,11 @@ By default, the font size is only changed in the active OS window,
this option will cause it to be changed in all OS windows. this option will cause it to be changed in all OS windows.
''') ''')
def cmd_set_font_size(global_opts, opts, args): def cmd_set_font_size(global_opts, opts, args):
'''
size+: The new font size in pts (a positive number)
all: Boolean whether to change font size in the current window or all windows
increment_op: The string ``+`` or ``-`` to interpret size as an increment
'''
if not args: if not args:
raise SystemExit('No font size specified') raise SystemExit('No font size specified')
fs = args[0] fs = args[0]
@ -143,7 +179,9 @@ def cmd_set_font_size(global_opts, opts, args):
def set_font_size(boss, window, payload): def set_font_size(boss, window, payload):
boss.change_font_size(payload['all'], payload['increment_op'], payload['size']) boss.change_font_size(
cmd_set_font_size.payload_get(payload, 'all'),
payload.get('increment_op', None), payload['size'])
# }}} # }}}
@ -170,6 +208,12 @@ are sent as is, not interpreted for escapes.
argspec='[TEXT TO SEND]' argspec='[TEXT TO SEND]'
) )
def cmd_send_text(global_opts, opts, args): def cmd_send_text(global_opts, opts, args):
'''
text+: The text being sent
is_binary+: If False text is interpreted as a python string literal instead of plain text
match: A string indicating the window to send text to
match_tab: A string indicating the tab to send text to
'''
limit = 1024 limit = 1024
ret = {'match': opts.match, 'is_binary': False, 'match_tab': opts.match_tab} ret = {'match': opts.match, 'is_binary': False, 'match_tab': opts.match_tab}
@ -235,12 +279,14 @@ def cmd_send_text(global_opts, opts, args):
def send_text(boss, window, payload): def send_text(boss, window, payload):
windows = [boss.active_window] windows = [boss.active_window]
match = payload['match'] pg = cmd_send_text.payload_get
match = pg(payload, 'match')
if match: if match:
windows = tuple(boss.match_windows(match)) windows = tuple(boss.match_windows(match))
if payload['match_tab']: mt = pg(payload, 'match_tab')
if mt:
windows = [] windows = []
tabs = tuple(boss.match_tabs(payload['match_tab'])) tabs = tuple(boss.match_tabs(mt))
if not tabs: if not tabs:
raise MatchError(payload['match_tab'], 'tabs') raise MatchError(payload['match_tab'], 'tabs')
for tab in tabs: for tab in tabs:
@ -269,19 +315,25 @@ want to allow other programs to change it afterwards, use this option.
argspec='TITLE ...' argspec='TITLE ...'
) )
def cmd_set_window_title(global_opts, opts, args): def cmd_set_window_title(global_opts, opts, args):
'''
title+: The new title
match: Which windows to change the title in
temporary: Boolean indicating if the change is temporary or permanent
'''
return {'title': ' '.join(args), 'match': opts.match, 'temporary': opts.temporary} return {'title': ' '.join(args), 'match': opts.match, 'temporary': opts.temporary}
def set_window_title(boss, window, payload): def set_window_title(boss, window, payload):
windows = [window or boss.active_window] windows = [window or boss.active_window]
match = payload['match'] pg = cmd_set_window_title.payload_get
match = pg(payload, 'match')
if match: if match:
windows = tuple(boss.match_windows(match)) windows = tuple(boss.match_windows(match))
if not windows: if not windows:
raise MatchError(match) raise MatchError(match)
for window in windows: for window in windows:
if window: if window:
if payload['temporary']: if pg(payload, 'temporary'):
window.override_title = None window.override_title = None
window.title_changed(payload['title']) window.title_changed(payload['title'])
else: else:

View File

@ -20,7 +20,7 @@ from .utils import TTYIO, parse_address_spec
def handle_cmd(boss, window, cmd): def handle_cmd(boss, window, cmd):
cmd = json.loads(cmd) cmd = json.loads(cmd)
v = cmd['version'] v = cmd['version']
no_response = cmd['no_response'] no_response = cmd.get('no_response', False)
if tuple(v)[:2] > version[:2]: if tuple(v)[:2] > version[:2]:
if no_response: if no_response:
return return