diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3545968 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Manifest.toml diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..a1bcc22 --- /dev/null +++ b/Project.toml @@ -0,0 +1,14 @@ +name = "GhosttyExtensions" +uuid = "64489478-8f8d-42d5-80e2-9f7f086a1b9c" +authors = ["Peter Pieske <71228733+piechologist@users.noreply.github.com>"] +version = "0.7.0" + +[deps] +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +TerminalPager = "0c614874-6106-40ed-a7c2-2f1cd0bff883" + +[compat] +TerminalPager = "0.6" +julia = "1.10" diff --git a/README.md b/README.md index 840ed03..fd23885 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,118 @@ -# GhosttyExtensions.jl +# GhosttyExtensions + A Julia package that supports some advanced features of the Ghostty terminal emulator. + +> Note: +> +> [Ghostty](https://ghostty.org) will be publicly released on 2024-12-31. +> +> [WezTerm](https://wezfurlong.org/wezterm/index.html) or +> [Kitty](https://sw.kovidgoyal.net/kitty/) should work as well. + +All features work over ssh. There's only one external dependency (TerminalPager.jl) and +_TTFP_, the time it adds to the first prompt, should not be noticeable. + +This package requires Julia 1.10 or higher. It has **not** been tested for compatibility with +other packages that alter the REPL e.g., OhMyREPL. + +## Features + +Shell integration: + +- OSC 2 terminal title. + Shows the active project and the remote hostname when connected via ssh. + +- OSC 52 pasteboard support with `pbcopy(x)` and `pbpaste()`. + Copy'n'paste that works over ssh. + +- OSC 133 prompt marking for the prompt modes `julia>`, `shell>`, and `help?>`. + Jump back and forth through the prompts or copy the output between two prompts. + +Inline plotting: + +- Kitty graphics protocol for use with Plots.jl. Plots are shown as PNGs in their original + sizes. There is no scaling. Adjust the plot size within Plots.jl (see below). This + approach makes plots less blurred and there's no need for any dependencies. Use + KittyTerminalImages.jl if you need more features. + +- Inline plotting is automatically activated in interactive sessions and works over ssh. + +- Switch back to the default (e.g., GKSQT.app on macOS) with `inlineplotting(false)`. + Plotting to a GUI app works only on the local machine. + +- In non-interactive scripts, call `inlineplotting()` once to initialize. + Remember to wrap the plot commands with `display(...)`. + +- Get the size of the terminal window in pixels with `pixelsize()`. Invoke `@help pixelsize` + for examples about adjusting the plot size or setting default sizes. + +Extra key bindings: + +- **F1:** call TerminalPager's `@help` for the selection or the word under the cursor + +- **F2:** wrap the buffer in parentheses and move the cursor to the start + +- **F12:** toggle the prefix `@time` + +- **Shift-F12:** toggle the prefix `@code_warntype` + +- **Meta-C:** copy the selection or buffer to the system pasteboard (via OSC 52) + +- **Meta-X:** cut the selection or buffer to the system pasteboard (via OSC 52) + +- **Meta-V:** paste from the system pasteboard and execute (via OSC 52) + +- **Shift-Option-Left, Shift-Option-Right:** select by word + +- **Shift-Command-Left, Shift-Command-Right:** select to the start/end of the line + +- **Shift-Option-Up, Shift-Option-Down:** select to the start/end of the buffer + +Sorry for the Mac specific key bindings. You can change the `extra_keymap` in +`src/GhosttyExtensions.jl`. + +Meta-C and Meta-X can be made much more convenient by binding them to Command-C/Command-X in +Ghostty's `~/.config/ghostty/config`. Currently, we need a separate keybind to copy GUI +selections made with the mouse as we overwrite the default binding: + +``` +keybind = opt+cmd+c=copy_to_clipboard +keybind = cmd+c=esc:C +keybind = cmd+x=esc:X +``` + +Meta-V is intended for automation. For instance, you may want to use AppleScript, +[Hammerspoon](https://hammerspoon.org), or some other app to copy code from your GUI editor +and paste & execute it in Ghostty. You'll be asked for permission to access the system +clipboard or you can opt in permanently in `~/.config/ghostty/config`: + +``` +clipboard-read = allow +``` + +## Installation + +This package is not available in Julia's general registry. It can be added or dev'ed with: + +```julia +Pkg> develop https://github.com/piechologist/GhosttyExtensions.jl +``` + +Add `using GhosttyExtensions` to your `~/.julia/config/startup.jl`. Note that loading +GhosttyExtensions manually after the REPL has been initialized won't work. + +TerminalPager.jl will be installed as a dependency and `@help`, `@out2pr`, +`@stdout_to_pager`, and `pager` re-exported. Please refer to its +[documentation](https://ronisbr.github.io/TerminalPager.jl/stable/). + +## Credits + +- [TerminalExtensions.jl](https://github.com/Keno/TerminalExtensions.jl) + +- [TerminalPager.jl](https://github.com/ronisbr/TerminalPager.jl) + +- [KittyTerminalImages.jl](https://github.com/simonschoelly/KittyTerminalImages.jl) + +- [Kitty terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) + +- The Julia 1.10 REPL, see `edit(Sys.STDLIB * "/REPL/src/LineEdit.jl")` diff --git a/src/GhosttyExtensions.jl b/src/GhosttyExtensions.jl new file mode 100644 index 0000000..3137761 --- /dev/null +++ b/src/GhosttyExtensions.jl @@ -0,0 +1,72 @@ +module GhosttyExtensions + +using Base64: base64decode, base64encode +using REPL +using REPL.LineEdit +using TerminalPager +import Base: display + +export TerminalPager, pager, @help, @out2pr, @stdout_to_pager +export inlineplotting, pixelsize +export pbcopy, pbpaste + +include("lineedit.jl") +include("plotting.jl") +include("shellintegration.jl") + +# Extra key bindings. +# +# Tip: the following keys are not used in LineEdit.jl and can be bound to custom functions: +# - Meta + all capital letters except O and W +# - Meta + any of aghijkoqrsvxz +# - ^o and ^v +# +# Note: the history keymap is active when the cursor is at the end of the buffer. It will +# swallow the first part of certain bindings and the remaining part will leak into the +# terminal. We need to add wildcards for these bindings to let them pass through. +# See `LineEdit.prefix_history_keymap` for the default wildcards. +const extra_keymap = Dict{Any,Any}( + "\eOP" => (s, o...) -> invoke_help(s), # F1 + "\eOQ" => (s, o...) -> parenthesize(s), # F2 + "\e[24~" => (s, o...) -> toggle_prefix(s, "@time"), # F12 + "\e[24;2~" => (s, o...) -> toggle_prefix(s, "@code_warntype"), # Shift-F12 + "\eC" => (s, o...) -> copy_region(s), + "\eX" => (s, o...) -> cut_region(s), + "\eV" => (s, o...) -> run_pasteboard(s), + # Shift-Option-Up/Down/Right/Left: + "\e[1;4A" => (s, o...) -> select_to_start_of_buffer(s), + "\e[1;4B" => (s, o...) -> select_to_end_of_buffer(s), + "\e[1;4C" => (s, o...) -> LineEdit.edit_shift_move(s, LineEdit.edit_move_word_right), + "\e[1;4D" => (s, o...) -> LineEdit.edit_shift_move(s, LineEdit.edit_move_word_left), + # Shift-Command-Right/Left: + "\e[1;10C" => (s, o...) -> select_to_end_of_line(s), + "\e[1;10D" => (s, o...) -> select_to_start_of_line(s), +) + +const extra_wildcards = Dict{Any,Any}( + "\e[24~" => "*", # F12 + "\e[24;2~" => "*", # Shift-F12 + "\e[1;4*" => "*", # Shift-Option-ArrowKeys + "\e[1;10*" => "*", # Shift-Command-ArrowKeys +) + +function __init__() + atreplinit() do repl + if isinteractive() && repl isa REPL.LineEditREPL + if isdefined(repl, :interface) + error("another package has already initialized the REPL") + end + + # Set up the REPL with the custom key bindings. + merge!(LineEdit.prefix_history_keymap, extra_wildcards) + repl.interface = REPL.setup_interface(repl; extra_repl_keymap=extra_keymap) + + # Set up the shell integration and KittyDisplay. + shellintegration(repl) + inlineplotting() + end + end + return nothing +end + +end # module GhosttyExtensions diff --git a/src/lineedit.jl b/src/lineedit.jl new file mode 100644 index 0000000..b22c559 --- /dev/null +++ b/src/lineedit.jl @@ -0,0 +1,137 @@ +# ------------------------------------------------------------------------------------------ +# Functions supplementing Julia's REPL.LineEdit.jl +# ------------------------------------------------------------------------------------------ + +"""Copied over from LineEdit.jl. Patched to match words containing @ or . (dot).""" +is_non_word_char(c::Char) = c in """ \t\n\"\\'`\$><=:;|&{}()[],+-*/?%^~""" + +"""Copied over from LineEdit.jl. Patched to use the modified is_non_word_char().""" +function current_word_with_dots(buf) + pos = position(buf) + if eof(buf) || is_non_word_char(peek(buf, Char)) + LineEdit.char_move_word_left(buf, is_non_word_char) + end + LineEdit.char_move_word_right(buf, is_non_word_char) + pend = position(buf) + LineEdit.char_move_word_left(buf, is_non_word_char) + pbegin = position(buf) + word = pend > pbegin ? String(buf.data[(pbegin + 1):pend]) : "" + seek(buf, pos) + return word +end + +"""Copy the selection or the whole buffer to the system pasteboard.""" +function copy_region(s) + if LineEdit.is_region_active(s) + pbcopy(LineEdit.content(s, LineEdit.region(s))) + LineEdit.edit_copy_region(s) + else + pbcopy(LineEdit.content(s)) + end + return nothing +end + +"""Cut the selection or the whole buffer to the system pasteboard.""" +function cut_region(s) + if LineEdit.is_region_active(s) + pbcopy(LineEdit.content(s, LineEdit.region(s))) + LineEdit.edit_kill_region(s) + else + pbcopy(LineEdit.content(s)) + LineEdit.edit_clear(s) + end + return nothing +end + +"""Call `@help` for the selection or the word under the cursor.""" +function invoke_help(s) + mode_name = LineEdit.guess_current_mode_name(s) + if mode_name ≡ :julia + if startswith(LineEdit.content(s), "@help ") + toggle_prefix(s, "@help") + else + if LineEdit.is_region_active(s) + word = LineEdit.content(s, LineEdit.region(s)) + else + word = current_word_with_dots(LineEdit.buffer(s)) + end + if !isempty(word) + LineEdit.edit_clear(s) + write(stdin.buffer, "@help ", word, "\n") + end + end + elseif mode_name ≡ :help + LineEdit.move_input_start(s) + write(stdin.buffer, "\b") + startswith(LineEdit.content(s), "?") && LineEdit.edit_delete(s) + LineEdit.refresh_line(s) + end + return nothing +end + +"""Wrap the whole buffer in parentheses and move the cursor to the start.""" +function parenthesize(s) + LineEdit.move_input_end(s) + LineEdit.edit_insert(s, ')') + LineEdit.move_input_start(s) + LineEdit.edit_insert(s, '(') + LineEdit.move_input_start(s) + LineEdit.refresh_line(s) + return nothing +end + +"""Paste the system pasteboard as bracketed paste and execute it.""" +function run_pasteboard(s) + LineEdit.edit_clear(s) + write(stdin.buffer, "\e[200~", strip(pbpaste()), "\e[201~\n") + return nothing +end + +"""Begin or extend a selection to the of start the buffer.""" +function select_to_start_of_buffer(s) + while position(s) > 0 + LineEdit.edit_shift_move(s, LineEdit.edit_move_word_left) + end + return nothing +end + +"""Begin or extend a selection to the of end the buffer.""" +function select_to_end_of_buffer(s) + while !eof(LineEdit.buffer(s)) + LineEdit.edit_shift_move(s, LineEdit.edit_move_word_right) + end + return nothing +end + +"""Begin or extend a selection to the of start the line.""" +function select_to_start_of_line(s) + while position(s) > 0 + LineEdit.edit_shift_move(s, LineEdit.edit_move_left) + position(s) == 0 && break + LineEdit.buffer(s).data[position(s)] == 0x0a && break + end + return nothing +end + +"""Begin or extend a selection to the of end the line.""" +function select_to_end_of_line(s) + while !eof(LineEdit.buffer(s)) + LineEdit.edit_shift_move(s, LineEdit.edit_move_right) + LineEdit.buffer(s).data[position(s)] == 0x0a && break + end + return nothing +end + +"""Prefix the buffer with `prefix ` or remove it if it's already there.""" +function toggle_prefix(s, prefix) + LineEdit.move_input_start(s) + if startswith(LineEdit.content(s), prefix * " ") + LineEdit.edit_delete_next_word(s) + LineEdit.edit_delete(s) + else + LineEdit.edit_insert(s, prefix * " ") + LineEdit.edit_move_left(s) + end + LineEdit.refresh_line(s) + return nothing +end diff --git a/src/plotting.jl b/src/plotting.jl new file mode 100644 index 0000000..8ef2ed8 --- /dev/null +++ b/src/plotting.jl @@ -0,0 +1,107 @@ +# ------------------------------------------------------------------------------------------ +# A new AbstractDisplay and functions for inline plotting with Plots.jl +# ------------------------------------------------------------------------------------------ + +struct KittyDisplay <: AbstractDisplay end + +""" + inlineplotting() -> Nothing + inlineplotting(false) -> Nothing + +Switch to inline plotting in the terminal or back to the default behavior. +""" +function inlineplotting(inline=true) + while KittyDisplay() ∈ Base.Multimedia.displays + Base.Multimedia.popdisplay(KittyDisplay()) + end + if inline + Base.Multimedia.pushdisplay(KittyDisplay()) + ENV["GKSwstype"] = "nul" # suppress launching of the GKSQT app (GR backend) + else + delete!(ENV, "GKSwstype") + end + return nothing +end + +function display(d::KittyDisplay, x) + if showable(MIME"image/png"(), x) + if isa(x, Vector{UInt8}) + display(d, MIME"image/png"(), x) + return nothing + elseif isa(x, Main.Plots.Plot) + io = IOBuffer() + Main.Plots.png(x, io) + display(d, MIME"image/png"(), take!(io)) + return nothing + end + end + throw(MethodError(display, (x,))) +end + +function display(d::KittyDisplay, m::MIME"image/png", png::Vector{UInt8}) + # https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference + # We place the plot a few pixels below the preceding prompt line with `Y=5`. + # At the end, we print a newline to avoid the next prompt overlapping the plot. + partitions = Iterators.partition(base64encode(png), 4096) + io = IOBuffer() + for (i, payload) in enumerate(partitions) + c = i == 1 ? "f=100,a=T,q=1,Y=5," : "" + m = i < length(partitions) ? "m=1;" : "m=0;" + write(io, "\e_G", c, m, payload, "\e\\") + end + write(stdout, take!(io), '\n') + return nothing +end + +""" + pixelsize() -> Tuple(width::Int, height::Int) + pixelsize(relative_height) + pixelsize(relative_height, relative_width) + pixelsize(relative_height; ratio=width_to_height_ratio) + +Return the size of the terminal window in pixels. With arguments, return a tuple that can be +passed to a plot command to set the size of the figure. + +# Examples + +Make plots that are a bit smaller than half of the terminal height to fit two plots in the +window. Make them twice as wide as they are tall. Add scaling to make the font bigger on +high resolution screens like Macs with Retina displays. +``` +using Plots +default(; size=pixelsize(0.45; ratio=2), thickness_scaling=1.5) +plot(plot(rand(33)), heatmap(rand(33,33)), layout=(1,2), xlab="(X)", ylab="(Y)") +``` + +Make a plot that is a third of the terminal height and as wide as possible. +``` +plot(rand(10); size=pixelsize(1/3)) +``` + +The terminal's cell size can be calculated with: +``` +height, width = pixelsize() +rows, columns = displaysize(stdout) +cell_height = height ÷ rows +cell_width = width ÷ columns +``` +""" +function pixelsize() + term = REPL.Terminals.TTYTerminal("xterm", stdin, stdout, stderr) + REPL.Terminals.raw!(term, true) + Base.start_reading(stdin) + print(stdout, "\e[14t") + data = readuntil(stdin, "t") + startswith(data, "\e[4;") || return (0, 0) + height, width = split(chopprefix(data, "\e[4;"), ';') + return parse(Int, width), parse(Int, height) +end + +function pixelsize(relative_height, relative_width=1; ratio=0) + width, height = pixelsize() + rows, columns = displaysize(stdout) + cell_height = height ÷ rows + h = floor(relative_height * rows) * cell_height # always fill whole rows + w = iszero(ratio) ? relative_width * width : ratio * h + return w, h +end diff --git a/src/shellintegration.jl b/src/shellintegration.jl new file mode 100644 index 0000000..401a943 --- /dev/null +++ b/src/shellintegration.jl @@ -0,0 +1,86 @@ +# ------------------------------------------------------------------------------------------ +# Functions for Ghostty's shell integration +# ------------------------------------------------------------------------------------------ + +""" + pbcopy(x) -> Nothing + +Copy the object `x` to the system pasteboard as text. +This uses OSC 52 and thus works via ssh. +""" +function pbcopy(x) + print("\e]52;c;", base64encode(string(x)), "\a") + return nothing +end + +""" + pbpaste() -> String + +Query the system pasteboard and return its content as `String`. +This uses OSC 52 and thus works via ssh. +""" +function pbpaste() + term = REPL.Terminals.TTYTerminal("xterm", stdin, stdout, stderr) + REPL.Terminals.raw!(term, true) + Base.start_reading(stdin) + print(stdout, "\e]52;c;?\a") + data = readuntil(stdin, "\e\\") + startswith(data, "\e]52;c;") || return "" + return String(base64decode(chopprefix(data, "\e]52;c;"))) +end + +function set_terminal_title() + remote_host = haskey(ENV, "SSH_TTY") ? gethostname() * " " : "" + project = dirname(Base.active_project()) + title = contains(project, "/.julia/environments/") ? "♻️ @" : "♻️ " + print("\e]2;", remote_host, title, basename(project), "\e\\") + return nothing +end + +function shellintegration(repl) + # Notes: + # 1. prompt_prefix & prompt_suffix may get fired many times when editing a command or + # scrolling through the command history. We use `isexecuting` to track the current + # state and print the post-exec mark only once. + # 2. We use `project` similarily. Base.ACTIVE_PROJECT is very cheap to access and we + # read it frequently to check if the project has changed. If it has, we call the + # relatively expensive set_terminal_title(). + # 3. Ghostty clears the prompt on window resize and sends SIGWINCH, expecting the shell + # to redraw the prompt. Since the REPL doesn't catch signals at all, we ask Ghostty + # not to clear the prompt with the parameter redraw=0 inside the prompt start mark. + isexecuting = true + project::Union{Nothing,String} = "not initialized yet" + + # Prompt marking and cursor shaping for the first three modes julia>, shell>, help?>. + for mode in repl.interface.modes[1:3] + prefix = mode.prompt_prefix + mode.prompt_prefix = function () + if isexecuting + isexecuting = false + # Print the post-exec mark, set the cursor shape to bar: + print("\e]133;D\a", "\e[5 q") + end + if project ≠ Base.ACTIVE_PROJECT.x + project = Base.ACTIVE_PROJECT.x + set_terminal_title() + end + # Prepend the prompt start mark: + return "\e]133;A;redraw=0\a" * (prefix isa Function ? prefix() : prefix) + end + + suffix = mode.prompt_suffix + mode.prompt_suffix = function () + # Append the prompt end mark: + return (suffix isa Function ? suffix() : suffix) * "\e]133;B\a" + end + + of = mode.on_done + mode.on_done = function (args...) + isexecuting = true + # Set the cursor shape to block, print the pre-exec mark: + print("\e[0 q", "\e]133;C\a") + return of(args...) + end + end + return nothing +end