Created
September 24, 2025 13:27
-
-
Save VinceGuidry/b44f02117d452bde7471dae2effb709a to your computer and use it in GitHub Desktop.
Kitty terminal protocol parsing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| require 'io/console' | |
| require 'ansi/terminal' | |
| require 'ansi/string' | |
| require 'pry' | |
| module Vinced | |
| class Keypress | |
| attr_reader :string, :parsed, :printable | |
| def initialize(string) | |
| @string = string | |
| @parsed = parse | |
| @printable = lookup | |
| end | |
| def printable? | |
| case string[0] | |
| when "\e" | |
| false | |
| when "\x7F" | |
| false | |
| when "\r" | |
| false | |
| when "\t" | |
| false | |
| else | |
| true | |
| end | |
| end | |
| def kitty_unprintable_lookup | |
| { | |
| "27" => :escape, | |
| "127" => :backspace, | |
| "13" => :enter, | |
| }[md['codepoint']] | |
| end | |
| def csi? = (string[0] == "\e") | |
| def bytes = string.bytes.map{|b| b.to_s(16)} | |
| def hex_bytes = bytes | |
| def ==(str) = (string == str) | |
| def md = parsed[1] | |
| def type = parsed[0] | |
| def inspect | |
| return "<Keypress type='#{type}' lookup='#{lookup}' mods='#{modifiers.join(' ')}' str=#{string.inspect}>" | |
| end | |
| def other_lookup | |
| { | |
| "\t" => :tab, | |
| "\x7F" => :backspace, | |
| "\x08" => :backspace, | |
| "\r" => :enter, | |
| }[md['other']] | |
| end | |
| def func_lookup | |
| { | |
| '27 u' => :escape, '13 u' => :enter, | |
| '9 u' => :tab, '127 u' => :backspace, | |
| "2 ~" => :insert, "3 ~" => :delete, | |
| "1 D" => :left, "D" => :left, | |
| "1 C" => :right, "C" => :right, | |
| "1 A" => :up, "A" => :up, | |
| "1 B" => :down, "B" => :down, | |
| "5 ~" => :page_up, "6 ~" => :page_down, | |
| "1 H" => :home, "7 ~" => :home, | |
| "1 F" => :end, "8 ~" => :end, | |
| "1 P" => :f1, "11 ~" => :f1, | |
| "1 Q" => :f2, "12 ~" => :f2, | |
| "13 ~" => :f3, "1 E" => :kp_begin, | |
| "1 S" => :f4, "14 ~" => :f4, | |
| "15 ~" => :f5, "17 ~" => :f6, | |
| "18 ~" => :f7, "19 ~" => :f8, | |
| "20 ~" => :f9, "21 ~" => :f10, | |
| "23 ~" => :f11, "24 ~" => :f12, | |
| }[cp_term] | |
| end | |
| def legacy_lookup | |
| { | |
| "A" => :up, | |
| "B" => :down, | |
| "C" => :right, | |
| "D" => :left, | |
| "E" => :kp_begin, | |
| "F" => :end, | |
| "H" => :home, | |
| "P" => :f1, | |
| "Q" => :f2, | |
| "S" => :f4, | |
| }[md['legacy']] | |
| end | |
| def cp_term | |
| return kitty_unprintable_lookup if kitty_unprintable_lookup | |
| [md['codepoint'].to_i].pack('U') | |
| end | |
| def lookup | |
| return other_lookup if type == :other | |
| return func_lookup if type == :func | |
| return legacy_lookup if type == :legacy | |
| return cp_term if type == :kitty | |
| return string | |
| end | |
| def modifiers | |
| m = md['modifiers'] rescue nil | |
| return [] if m.nil? | |
| s = m.to_i.-(1).to_s(2) | |
| s.prepend("0" * (8 - s.length)) | |
| s = s.chars.map(&:to_i) | |
| a = [] | |
| m = [:num_lock, :caps_lock, :meta, :hyper, :super, :ctrl, :alt, :shift] | |
| m.each_index do |i| | |
| next if m[i].nil? | |
| a << m[i] if s[i] == 1 | |
| end | |
| a.reverse | |
| end | |
| def other_parse_regex | |
| /^(?<other>[\r\t\x7F\x08])$/ | |
| end | |
| def legacy_parse_regex | |
| /^\e\[(?<legacy>\w)$/ | |
| end | |
| def func_parse_regex | |
| /^\e\[((?<codepoint>\d*);(?<modifiers>\d+))?(?<term>[ABCDEFHPQS~])$/ | |
| end | |
| def kitty_parse_regex | |
| /^\e\[(?<codepoint>\d+)(;(?<modifiers>\d+))?(?<term>u)$/ | |
| end | |
| def parse | |
| return [:kitty, Regexp.last_match] if !kitty_parse_regex.match(string).nil? | |
| return [:func, Regexp.last_match] if !func_parse_regex.match(string).nil? | |
| return [:legacy, Regexp.last_match] if !legacy_parse_regex.match(string).nil? | |
| return [:other, Regexp.last_match] if !other_parse_regex.match(string).nil? | |
| return [:unicode, string] | |
| end | |
| def character | |
| return unprintable if unprintable | |
| return string if printable? | |
| [codepoint].pack('U') | |
| end | |
| def to_s | |
| return string if printable? | |
| return string.bytes.join(' ') | |
| end | |
| end | |
| class Terminal | |
| def get_key | |
| begin | |
| str = STDIN.read_nonblock(100) | |
| rescue IO::WaitReadable | |
| IO.select([STDIN]) | |
| retry | |
| end | |
| Keypress.new(str) | |
| end | |
| def raw | |
| STDIN.raw do | |
| kitty_disambig do | |
| yield | |
| end | |
| end | |
| end | |
| def cooked | |
| STDIN.cooked do | |
| yield | |
| end | |
| end | |
| def kitty_supported? | |
| end | |
| def kitty_disambig | |
| send_control_char(">1") | |
| yield | |
| send_control_char("<") | |
| end | |
| def eof? | |
| STDIN.eof? | |
| end | |
| def csi = [0x1b, 0x5b] | |
| def send_ansi(string) | |
| bytearray = csi.concat(string.bytes) | |
| STDOUT.print(bytearray.pack('U*')) | |
| end | |
| def send_control_char(string) | |
| term = [0x75] | |
| bytearray = csi.concat(string.bytes).concat(term) | |
| STDOUT.print(bytearray.pack('U*')) | |
| end | |
| def clear_screen | |
| send_ansi("2J") | |
| send_ansi(";H") | |
| end | |
| end | |
| class Editor | |
| attr_reader :term | |
| def initialize(term) | |
| @term = term | |
| end | |
| def do_pry(c) | |
| term.cooked do | |
| binding.pry | |
| end | |
| end | |
| def run | |
| c = nil | |
| term.raw do | |
| term.clear_screen | |
| loop do | |
| c = term.get_key | |
| print c.inspect | |
| print "\r\n" | |
| do_pry(c) if c == 'p' | |
| break if c == 'q' or term.eof? | |
| end | |
| print "\r\n" | |
| end | |
| end | |
| end | |
| end | |
| include Vinced | |
| if ARGV.include?('-p') | |
| t = Terminal.new | |
| binding.pry | |
| else | |
| Editor.new(Terminal.new).run | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment