Skip to content
lambda-symbolicsPublic

About

(C)ommon (L)isp L(ine) (Edi)tor

Resources

Stars

6 stars

Watchers

0 watching

Forks

Latest commit

 

History

74 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Clinedi

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.

Editor snapshots and modal sessions

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.

Buffered transports and native modes

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.

Loading

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)

Incremental editor

(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-input represents Ctrl-D and follows the usual delete-or-EOF behavior
  • :stream-end represents physical stream EOF; handling it returns the :end-of-input action and keeps partial text
  • :insert-newline adds an explicit newline
  • Arrow events return :up or :down so event-driven callers can invoke line-editor-move-vertical with 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 b become :word-left
  • Ctrl-Right, Alt-Right, and ESC f become :word-right
  • Ctrl-W, Ctrl-Backspace, ESC Backspace, and ESC DEL become :kill-word
  • ESC d becomes :kill-word-right

Programmable keymaps

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.

Candidate selection

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))

Input pump

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))

Pasted paths

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.

Blocking frontend

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.

Transcript viewport

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.

Semantic prompt markers and xterm controls

clinedi:semantic-prompt-marker-sequence returns OSC 133 controls for terminal shell integration:

  • :prompt-start, with :redraw-p nil adding redraw=0 for applications that repaint their own prompt after a resize
  • :input-start
  • :execution-start
  • :command-finished, with a nonnegative :status defaulting 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.

Web URLs and hyperlinks

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.

Tests

./check

Part of the Lambda Symbolics library shelf.

About

(C)ommon (L)isp L(ine) (Edi)tor

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages