Clinedi is a portable Common Lisp line editor for terminal applications. It separates a Unicode-aware incremental editor from its blocking terminal frontend, so applications can either feed it semantic events or use it as a complete interactive input loop.
The editor handles:
- extended grapheme clusters
- terminal-cell layout
- wrapped multiline input
- history navigation
- bracketed paste
- completion presentation
- syntax-highlighting callbacks
- ghost-text suggestions
The application owns shell parsing, completion policy and history persistence.
Use line-editor-snapshot and line-editor-restore to suspend completion
previews without losing history traversal, its saved draft, or the cursor.
Snapshots are reusable and own their text and history copies. Use
line-editor-history-navigating-p to inspect traversal and
line-editor-replace-history to replace bounded history while restoring the
original draft. Persist history in the application.
Create a selection-session with make-selection-session. Supply opaque
:items, an :identity-key, an :identity-test, and a string-valued
:search-key. Enable :search-p for case-insensitive, whitespace-separated
query terms. Use selection-session-handle-event in an existing event loop,
or run-selection-session with transport and presentation callbacks.
Read selection-session-selector for navigation and viewport state, and
selection-session-query for the current query. Replace candidate snapshots
with selection-session-replace-items; select a stable designator with
selection-session-select-id. Candidate labels may coincide without losing
selection identity.
For the modal loop, supply :read-event, :input-ready-p, :poll-interval,
:refresh, :paint, and optionally :call-with-lock. Query pending resizes
in :refresh. Return true after repainting there to omit a duplicate paint.
The loop queries it before readiness and again before event handling.
:on-event receives (event selector) and returns NIL for default handling,
:continue, (:accept value), or (:cancel). Handle :poll for background
updates. Keep titles, hints and application-specific actions in the callbacks.
:on-close runs on every exit, including a failed :on-open.
Use stream-terminal-create for buffered stream input and trusted presentation
output. Call terminal-start, terminal-read-event, terminal-input-ready-p,
terminal-write, terminal-flush, and terminal-stop. Plain bursts become one
:insert event; multiline bursts become sanitized :paste events. Mixed input
retains pending characters between reads. Configure a custom :event-decoder
with (stream &key escape-delay) and an :event-prefix-p-function when an
application-defined prefix must precede multiline-paste classification.
Use read-paste-burst to implement literal-paste bindings with explicit idle
and character limits. Choose styling with :styling-p-function.
Load the optional clinedi/posix system on SBCL, then instantiate
posix-terminal with :input-stream, :output-stream, and
:input-file-descriptor. Check the descriptor rather than the stream wrapper
for interactive mode. Read native dimensions with terminal-file-descriptor-size.
The transport falls back to line events for non-TTY input. Startup and shutdown
are idempotent. Failed activation rolls back partial protocols and native mode;
shutdown attempts every cleanup and reports the first failure as terminal-error.
For another native backend, implement terminal-capture-input-mode,
terminal-activate-input-mode, and terminal-restore-input-mode on a subclass.
terminal-read-concealed-line reads one secret line, such as an API key, with
echo off: it turns echo off through terminal-disable-input-echo, reads the
line, strips a bracketed-paste wrapper, and restores the mode exactly once, also
when the read fails. When echo cannot be turned off on an interactive
descriptor it signals terminal-error before reading anything.
Call terminal-current-size for rows and columns with per-dimension fallbacks:
native adapter dimensions, interactive tput, LINES/COLUMNS, then defaults.
Pass :file-descriptor for native lookup and :terminal-io for the tput
interactivity check. Customize :default-rows and :default-columns as needed.
Without a native adapter, use the remaining fallbacks; pass
:file-descriptor nil to skip native lookup.
Clinedi is an ASDF system. It uses cl-colorist for ANSI text styling and control-sequence parsing, and trivial-gray-streams for its streams.
(ql:quickload :clinedi)For local Quicklisp development, either place the checkout directly below
~/quicklisp/local-projects/, or add its parent directory before registering
local projects:
(pushnew #P"/root/common-lisp/"
ql:*local-project-directories*
:test #'equal)
(ql:register-local-projects)(let ((editor (clinedi:make-line-editor :history '("git status"))))
(clinedi:line-editor-handle-event editor '(:insert "echo 猫"))
(clinedi:line-editor-handle-event editor :left)
(clinedi:line-editor-text editor))line-editor-handle-event accepts semantic editing events and returns an
action plus an optional payload. This API is suitable for event-driven terminal
UIs that own their repaint loop.
For application-owned transcript viewports, read-event also decodes Page Up
and Page Down as :page-up and :page-down, Ctrl-Page Up and Ctrl-Page Down
as :previous-section and :next-section, Ctrl-Home and Ctrl-End as
:scroll-top and :scroll-bottom, SGR mouse wheel reports as (:scroll -1)
or (:scroll 1), and an SGR left-button press as (:click column row) with
one-based coordinates. Releases, motion, and other buttons decode as :ignore.
Handle these in the application's viewport before dispatching to the editor.
Enable mouse reporting only while that viewport owns the terminal.
:end-of-inputrepresents Ctrl-D and follows the usual delete-or-EOF behavior:stream-endrepresents physical stream EOF; handling it returns the:end-of-inputaction and keeps partial text:insert-newlineadds an explicit newline- Arrow events return
:upor:downso event-driven callers can invokeline-editor-move-verticalwith their current terminal width and prompt width, falling back to explicit history events when it reports no adjacent visual row
Pass :history-match-function to the constructor to filter those history
events. The function receives the complete draft captured when traversal begins
and each candidate entry. Down past the newest match restores that draft and
its original cursor. An empty draft traverses every entry.
Pass :word-delimiter-mode-p t to make word movement and word deletion stop at
delimiters as well as whitespace. The default delimiter list is -, _, /,
., and :; override it with :word-delimiters.
line-editor-toggle-word-delimiter-mode and the built-in
:toggle-word-delimiter-mode command switch the mode while an editor is active.
read-event maps these sequences to the same commands:
- Ctrl-U becomes
:kill-line, deleting text before the cursor and moving it to the start of the buffer - Ctrl-K becomes
:kill-to-end, deleting text after the cursor - Ctrl-Left, Alt-Left, and ESC
bbecome:word-left - Ctrl-Right, Alt-Right, and ESC
fbecome:word-right - Ctrl-W, Ctrl-Backspace, ESC Backspace, and ESC DEL become
:kill-word - ESC
dbecomes:kill-word-right
Clinedi decodes terminal input into semantic events, then resolves each event
through the editor's keymap. default-line-editor-keymap returns a fresh map
with the standard behavior, so an application can customize its own copy:
(defparameter *application-keymap*
(clinedi:default-line-editor-keymap))
;; Give Up and Down unconditional history behavior.
(clinedi:keymap-bind *application-keymap* :up :history-previous)
(clinedi:keymap-bind *application-keymap* :down :history-next)
(clinedi:edit-line "> " :keymap *application-keymap*)A binding maps an event to a built-in semantic command, a function, or a
non-keyword fbound symbol. Custom commands receive the editor and the original
event, and return the same action and optional payload pair as
line-editor-handle-event. They can call line-editor-execute-command to reuse
built-in behavior. line-editor-command-for-event exposes resolution separately
for event loops that need to inspect a command before executing it.
Keymaps support parent fallback. For a compound event such as
(:insert "x"), lookup checks that exact event, then :insert, before moving
to the parent. keymap-unbind removes a local binding and reveals its parent;
binding an event to nil masks the parent. copy-keymap copies every map and
binding table in the parent chain, while keymap-bindings returns a detached
snapshot of one map's local entries.
clinedi:selector is application-neutral navigation and viewport state for
pickers and interactive completions. Candidate values are opaque, so an
application can use strings for file completion, model records for a picker,
or any other values while retaining control of filtering, labels, styling and
acceptance policy.
A selector can arrange candidates vertically or in a row-major grid that measures candidate cell widths against the available terminal width.
- Arrow keys navigate that geometry
- Tab and Shift-Tab cycle candidates forward and backward
- Enter accepts
- ordinary editing input dismisses the chooser while returning the selected value
(let ((selector (clinedi:make-selector
:items '("source/" "source/main.lisp")
:arrangement :grid)))
(clinedi:selector-arrange selector 80
:width-function #'clinedi:text-cell-width)
(clinedi:selector-handle-event selector :history-next)
(clinedi:selector-selected-item selector))A multi-threaded program reads its terminal on one thread and still needs
modal pickers that take the keyboard for a while. clinedi:make-input-pump
creates that reader from a non-blocking :ready-function and a
:step-function that reads and handles one event (returning :stop ends the
reader). input-pump-call-with-exclusive-input gives a modal function sole
ownership of the terminal: on the reader thread it runs in place, elsewhere it
pauses and joins the reader and restarts it afterwards. Pauses nest; only the
outermost one stops and restarts the thread, and :startable-p-function can
veto a restart while the program shuts down.
(let ((pump (clinedi:make-input-pump
:ready-function (lambda () (terminal-input-ready-p terminal))
:step-function (lambda () (handle (terminal-read-event terminal))))))
(clinedi:input-pump-start pump)
(clinedi:input-pump-call-with-exclusive-input
pump (lambda () (run-picker terminal)))
(clinedi:input-pump-stop pump))Terminals paste a dragged file as its path, quoted or escaped as a shell would
read it, or as a file:// URL. clinedi:pasted-path returns the local path
such a paste names, decoding percent-escaped UTF-8 in a file URL, or nil when
the text is not one path; pasted-path-token only reads the shell quoting.
clinedi:edit-line owns key decoding and repainting while delegating terminal
raw mode, terminal size, completion, highlighting and suggestions to callbacks.
This keeps terminal policy and application semantics outside the library. The
terminal-size callback is refreshed while input is active, so wrapped text,
ghost suggestions and completion layouts follow terminal resizes. Pass
:keymap to customize command dispatch.
Ambiguous completions open a live selector below the edited text. The default
:completion-arrangement :grid fits as many measured columns as the terminal
width permits and collapses to a vertical list in narrow terminals. Callers can
request :vertical explicitly.
live-region-append-and-present appends and replacement-repaints in one
terminal write and flush for streaming applications. An optional maximum-rows
keeps long multiline content inside a cursor-following viewport while retaining
the complete presentation for later repainting. live-region-resize reconciles
the painted rows with terminal reflow before retracting them. Pass
:repaint-p nil when the application will immediately call
live-region-present.
Applications that manage their own presentation can use clinedi:screen-window
to obtain grapheme-safe start, end, and cursor indexes for the same bounded
multiline viewport behavior.
clinedi:make-transcript-viewport holds a scrollable transcript for a
fullscreen application. transcript-viewport-append adds plain text, its
styled display, and optional (start end action) click regions such as
cl-termdown's widget regions. Chunks stay unwrapped and each wrapped row
remembers the characters it shows, so transcript-viewport-resize reflows
the whole transcript while keeping the same first visible line.
transcript-viewport-layout fits the viewport to a height, optionally
counting unfinished rows shown after it, and returns the first visible row;
transcript-viewport-row-display returns each row's trusted display and
transcript-viewport-row-text its plain characters;
transcript-viewport-chunk-count and -chunk-text read the appended chunks.
transcript-viewport-scroll, -scroll-to-top, -follow and -page-rows
move it, and following resumes at the newest output.
transcript-viewport-jump moves to the nearest row a predicate accepts, such
as a message header. transcript-viewport-hit maps a mouse position to the
click region action, the chunk text, and the character index under it.
transcript-viewport-checkpoint and -rollback undo appends and scrolling
when painting fails.
clinedi:make-frame-painter paints such a screen. frame-painter-paint
takes trusted display rows, the screen height and width, the cursor position
and visibility, and a write function that must write and flush the controls it
receives. Rows are drawn at absolute positions with autowrap disabled, only
rows that changed since the last frame are rewritten, and a write that fails
or a size change makes the next frame repaint completely, as does
frame-painter-invalidate. frame-painter-frame returns the rows last
written.
clinedi:semantic-prompt-marker-sequence returns OSC 133 controls for terminal
shell integration:
:prompt-start, with:redraw-p niladdingredraw=0for applications that repaint their own prompt after a resize:input-start:execution-start:command-finished, with a nonnegative:statusdefaulting to zero
clinedi:window-title-sequence returns the OSC 0 window title control with
control characters removed and newlines joined. clinedi:default-color-sequence
takes :foreground or :background and a Colorist RGB color and returns the
OSC 10 or 11 control setting that terminal default;
clinedi:default-color-reset-sequence returns the OSC 110 or 111 control
restoring it. Every control ends with ST.
For a fullscreen viewport, clinedi:alternate-screen-enter-sequence and
clinedi:alternate-screen-leave-sequence switch to and from the alternate
screen buffer, and clinedi:mouse-reporting-enable-sequence and
clinedi:mouse-reporting-disable-sequence turn SGR button reports on and off,
which read-event decodes as :click and :scroll events.
clinedi:make-mode-tracking-output-stream wraps an output stream, such as the
socket a remote session's output arrives on, and forwards everything unchanged
while recognizing the modes that output imposes: alternate screen, mouse
reporting, SGR mouse encoding, bracketed paste, hidden cursor, disabled
autowrap, and OSC 10 or 11 default colors. Controls split across writes are
still recognized. mode-tracking-output-stream-imposed-modes lists the modes
currently held, and mode-tracking-output-stream-restore writes the controls
returning each to its default, so a dropped connection never leaves the local
terminal in the remote application's state. The stream is built on
trivial-gray-streams.
The application chooses when these trusted controls are written and flushed.
clinedi:url-ranges returns the character ranges of the http and https
URLs in a string. A URL runs to whitespace or a bracketing character and sheds
trailing sentence punctuation and unbalanced closing brackets.
clinedi:url-at returns the URL covering a character offset, and
clinedi:web-url-p accepts a string that is exactly one URL.
clinedi:ansi-hyperlink wraps text in an OSC 8 hyperlink, and
clinedi:ansi-link-urls links every URL in a string to itself. Both leave the
visible text unchanged, so ansi-strip and wrap-styled-text treat the result
like the plain string; wrapped rows reopen the link. Targets that are not
printable ASCII stay unlinked, and neither function emits controls while
*presentation-enabled* is false.
./checkPart of the Lambda Symbolics library shelf.