Skip to content

Commit

Permalink
Initial project version
Browse files Browse the repository at this point in the history
  • Loading branch information
piechologist committed Dec 12, 2024
1 parent f8f9f07 commit 0392aa9
Show file tree
Hide file tree
Showing 7 changed files with 535 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
Manifest.toml
14 changes: 14 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name = "GhosttyExtensions"
uuid = "64489478-8f8d-42d5-80e2-9f7f086a1b9c"
authors = ["Peter Pieske <[email protected]>"]
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"
118 changes: 117 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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")`
72 changes: 72 additions & 0 deletions src/GhosttyExtensions.jl
Original file line number Diff line number Diff line change
@@ -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
137 changes: 137 additions & 0 deletions src/lineedit.jl
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 0392aa9

Please sign in to comment.