Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
dd15bc5
plan
TimoKramer Aug 31, 2026
a80d88c
feat: adaptive colors and terminal color environment detection
TimoKramer Aug 31, 2026
1af2dae
docs: document adaptive colors, profiles and :environment
TimoKramer Aug 31, 2026
574020c
docs: record phase 0 completion in plan
TimoKramer Aug 31, 2026
8604e87
feat: pin the color environment via run options
TimoKramer Sep 11, 2026
7567f71
fix: delegate nearest-color matching to JLine
TimoKramer Sep 11, 2026
cf305d8
updated plan and adr
TimoKramer Sep 13, 2026
e83cb43
chore: bump JLine to 4.4.5 and record the palette fixes
TimoKramer Sep 19, 2026
4a61d41
fix: throw a named ex-info for invalid hex colors
TimoKramer Sep 19, 2026
8770e0b
docs: add a changelog and correct the README
TimoKramer Sep 19, 2026
abd84e9
chore: bump version to 0.3
TimoKramer Sep 19, 2026
00bd862
fix: resolve "esc", "pgup" and "pgdown" in key-match?
TimoKramer Sep 19, 2026
1b6d65a
fix: apply :strikethrough and normalize bare :padding/:margin values
TimoKramer Sep 19, 2026
61d536e
perf: skip the render when update leaves the state unchanged
TimoKramer Sep 19, 2026
67cc68b
perf: drop per-frame copies, reflection and boxing from render paths
TimoKramer Sep 19, 2026
2b720ea
chore: check for reflection in CI, and expand the committer email
TimoKramer Sep 19, 2026
d79c787
docs: changelog for the correctness fixes, and what they turned up
TimoKramer Sep 19, 2026
c8df3c3
feat: add charm.ansi.sanitize
TimoKramer Sep 19, 2026
fd11e37
fix: escape the window title and cap the clipboard payload
TimoKramer Sep 19, 2026
1226060
fix: restore signal handlers and the terminal on exit
TimoKramer Sep 19, 2026
02f4371
fix: sanitize content before it reaches the terminal
TimoKramer Sep 19, 2026
996a62b
chore: pin CI actions and the babashka installer
TimoKramer Sep 19, 2026
d35548a
docs: changelog for the security work, and two corrected premises
TimoKramer Sep 19, 2026
dee60e2
docs: record why Terminal.handle is unusable under babashka
TimoKramer Sep 19, 2026
1110779
perf: block for messages, drain them, and render once a frame
TimoKramer Sep 20, 2026
0d035d1
feat: add :bracketed-paste and :ctrl-c options
TimoKramer Sep 20, 2026
39bd1a7
docs: document frames, Ctrl+C, pastes and blocking commands
TimoKramer Sep 20, 2026
fb78239
docs: changelog for the event loop rewrite
TimoKramer Sep 20, 2026
05b5ce1
perf: run command bodies on virtual threads
TimoKramer Sep 20, 2026
092230c
docs: make ADR 008 self-contained
TimoKramer Sep 20, 2026
0ebcb8b
docs: add ADR 009 on frame pacing and command scheduling
TimoKramer Sep 20, 2026
8abfe72
docs: fix the ADR 003 heading and publish ADR 008
TimoKramer Sep 20, 2026
61b30b7
docs: no mention of plans in commits
TimoKramer Sep 20, 2026
3401b51
perf: parse each line once per frame, and stop losing styling when cu…
TimoKramer Sep 20, 2026
dce296f
docs: changelog for the parse-once render path
TimoKramer Sep 20, 2026
9856488
fix: stop styling from rewriting line-drawing characters as ASCII
TimoKramer Sep 20, 2026
06d6d18
docs: reject ADR 008, with the measurements that settled it
TimoKramer Sep 20, 2026
e4e89d9
refactor: keep the run-walking emitter to the one caller that needs it
TimoKramer Sep 20, 2026
2d0b36b
feat: add merge-style and an :overflow option; fix component id colli…
TimoKramer Sep 20, 2026
9d96da1
docs: changelog for the ergonomics batch
TimoKramer Sep 20, 2026
7569f23
docs: state the API stability expectation in AGENTS.md
TimoKramer Sep 20, 2026
92ddd42
docs: document the viewport and table components
TimoKramer Sep 20, 2026
17ec34d
test: assert on the emulated screen, not on escape bytes
TimoKramer Sep 20, 2026
21ec142
docs: note the screen-grid test tier and its babashka constraint
TimoKramer Sep 20, 2026
281ed80
docs: derive the chrome height instead of hard-coding it
TimoKramer Sep 20, 2026
24c4638
docs: point at the layout container issue
TimoKramer Sep 20, 2026
deb38eb
test: cross-check the key table against JLine's parser
TimoKramer Sep 20, 2026
4cac250
doc: dark themed example talk
TimoKramer Sep 25, 2026
201a425
ci: no need to switch to 0.3
TimoKramer Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@ jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- uses: actions/setup-java@v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '25'

- uses: DeLaGuardo/setup-clojure@13.4
- uses: DeLaGuardo/setup-clojure@3fe9b3ae632c6758d0b7757b0838606ef4287b08 # 13.4
with:
cli: latest
bb: latest
clj-kondo: latest
cljfmt: latest

- uses: actions/cache@v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.m2/repository
Expand All @@ -38,14 +38,22 @@ jobs:
run: cljfmt check

- name: Lint
run: clj-kondo --fail-level error --lint src test
run: clj-kondo --fail-level error --lint src test test-jvm

- name: Reflection check
run: clojure -M:reflection

- name: Test
run: clojure -M:test

- name: Test bb
# The installer is pinned to a release tag rather than master, so a push
# to that branch cannot change what this step executes. charm needs
# JLine 4.4.5, which is still only in babashka's dev builds, so the
# build it fetches is necessarily a moving target.
run: |
bash <(curl https://raw.githubusercontent.com/babashka/babashka/master/install) --dev-build --dir /tmp
curl -fsSL -o /tmp/install-bb https://raw.githubusercontent.com/babashka/babashka/v1.13.223/install
bash /tmp/install-bb --dev-build --dir /tmp
/tmp/bb test:bb

# - name: Run vhs
Expand All @@ -61,7 +69,7 @@ jobs:
run: clojure -T:build jar

- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: charm-jar
path: target/
Expand All @@ -73,20 +81,20 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- uses: actions/setup-java@v4
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '25'

- uses: DeLaGuardo/setup-clojure@13.4
- uses: DeLaGuardo/setup-clojure@3fe9b3ae632c6758d0b7757b0838606ef4287b08 # 13.4
with:
cli: latest

- uses: actions/cache@v4
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.m2/repository
Expand All @@ -95,7 +103,7 @@ jobs:
restore-keys: deps-

- name: Restore artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
name: charm-jar
path: target/
Expand All @@ -111,5 +119,5 @@ jobs:
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }}
run: |
git config set user.name "Timo Kramer"
git config set user.email GIT_COMMITTER_EMAIL
git config set user.email "$GIT_COMMITTER_EMAIL"
clojure -T:build tag-release
35 changes: 35 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ With timeout (milliseconds)
The REPL session persists between evaluations - namespaces and state are maintained.
Always use `:reload` when requiring namespaces to pick up changes.

# API Stability

charm.clj is a library. Its public API is used by applications we cannot see, so a
break is a cost to justify before making it, not a note to add afterwards.

Public is every namespace whose `ns` form does *not* carry `^:no-doc`:
`charm.program`, `charm.message`, `charm.style.core`, `charm.style.border`,
`charm.style.overlay`, `charm.ansi.sanitize` and the components. Everything marked
`^:no-doc` is internal and free to change.

- Prefer adding to the API over changing it. A new option whose default preserves
today's behaviour breaks nobody.
- A nicer design is not a serious reason. Do not change what an existing function
returns, or what an existing option means, to tidy something up.
- Serious reasons: a bug that cannot be fixed otherwise, a security problem, or a
documented feature that never worked. Say which one applies.
- Prefer a break that fails loudly over one that keeps compiling and quietly
behaves differently.
- Deprecate before deleting when it costs little.
- Record every break in `CHANGELOG.md`, marked **Potentially breaking**, and write
an ADR when the reasoning is worth keeping.

# ADRs

An ADR records a decision as it was made. Do not rewrite one so that it reads like
what is true now.

- Changing `Status` is part of the lifecycle: Proposed becomes Accepted, or
Rejected or Superseded when something overtakes it. Say what overtook it.
- Anything learned after the decision goes in an appended, dated addendum, not
into the original Context or Decision.
- A decision that replaces another gets its own ADR, and the old one points to it.

# Code Style

## Keep it simple
Expand Down Expand Up @@ -55,6 +88,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/): `type: descrip

Types: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `chore`

Never mention a plan because a plan is ephemeral and internal. Rather propose to write an ADR instead.

# Testing

## Running examples
Expand Down
236 changes: 236 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
# Changelog

Notable changes to charm.clj, in the style of
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Versions are `MAJOR.MINOR.<commit count>` — the last number comes from
`git-count-revs` at release time. Releases before 0.3 are recorded only in the
[git tags](https://github.com/TimoKramer/charm.clj/tags).

## Unreleased (0.3)

Light/dark adaptive styling, colors written as plain values now actually apply,
a round of correctness fixes to documented features that silently did nothing,
content a program did not author no longer reaches the terminal as instructions,
and an event loop that no longer caps input at about 100 messages a second.

### Added

- **`merge-style`**, which returns a copy of a style with the named options
changed. `(merge base variant)` overwrites the base with the variant's
*defaults* - nil colors, false attributes - rather than inheriting them, which
is the opposite of what building a variant of a base style wants.
- **`:overflow` option on `run`**, deciding which end the renderer drops lines
from when a view is taller than the terminal. `:top` (the default, unchanged
behaviour) keeps the last lines; `:bottom` keeps the first ones, which is
usually what a full-screen program wants.
- **`:bracketed-paste` option on `run`** (default `false`). With it on, a paste
arrives as a single `:paste` message carrying the whole text, instead of one
key press per character that an application cannot tell apart from fast
typing. `msg/paste` and `msg/paste?` come with it. Off by default, because a
program that handles only key presses would otherwise stop seeing pastes.
- **`:ctrl-c` option on `run`** (default `:quit`). Ctrl+C now stops the program
before `update` sees it, so a program that does not handle it can still be
killed from the keyboard. Pass `:message` to take it over - to confirm before
quitting, say - and quitting becomes the application's job.
- **`charm.ansi.sanitize`.** `sanitize` keeps SGR styling and drops every other
escape sequence, plus every control character but newline and tab; `strip`
drops the styling too, leaving the text a terminal would display;
`strip-controls` removes control characters entirely, for strings that go
inside a sequence charm writes itself. All three return their argument
unchanged when there is nothing to remove.
- **`:sanitize` option on `run`** (default `true`), which sanitizes the view
before it is written. Set it to `false` only for a view that authors its own
control sequences.
- **Adaptive colors.** `adaptive` takes a light and a dark variant and resolves
against the terminal background at render time:

```clojure
(style/style :fg (style/adaptive (style/hex "#333333")
(style/hex "#dddddd")))
```

- **Terminal environment detection.** `run` detects the color profile and the
background at startup, binds both for the lifetime of the program, and sends
an `:environment` message carrying `:color-profile` and `:dark-background?`,
so applications can branch on the environment themselves.
- **`:color-profile` and `:dark-background?` options on `run`**, which pin the
environment instead of detecting it — for a terminal that misreports itself,
or a test that needs a fixed environment. Pinning `:dark-background?` also
skips the OSC 11 background query, and with it the probe timeout that a
terminal which never answers costs at every startup.
- **`charm.terminal/dark-background?`**, which queries the terminal's background
and assumes dark when the terminal does not answer.

### Changed

- **`charm.input.keys/key-matches?` is gone.** It duplicated `msg/key-match?`'s
pattern logic against event maps rather than messages, and had no callers.
`msg/key-match?` is the one entry point. **Potentially breaking** for anything
reaching into that internal namespace.
- **Each line is parsed once per frame.** `render!` measured a line, cut it
through a string-returning truncate, then parsed the result again - up to three
`AttributedString/fromAnsi` passes per line per frame. Measured on 40 lines of
80 styled columns: 165 µs to 83 µs when the lines fit, 312 µs to 116 µs when
they need cutting.
- **The event loop blocks for messages instead of polling, and renders once a
frame.** It used to sleep 10 ms every iteration and then handle exactly one
message, so every keystroke waited up to 10 ms and throughput was capped near
100 messages a second: a 300-character paste took three seconds. It now waits
on the channel, handles everything queued, and renders at most once per
`:fps`. Measured on the loop alone: 300 messages in 3 ms, 5000 in 7 ms.
- **`:fps` does something.** It was documented, stored and never read - every
single message triggered a full `view` and terminal diff. It is now the redraw
ceiling, and a burst of messages costs one frame rather than one per message.
The frame a program quits on is still drawn, so an inline program's last view
is not lost.
- **Command bodies run on virtual threads** (`a/io-thread`), not in a `go` block.
A command is where a program does its blocking work, which is exactly what a
`go` block must not do; on the eight-thread dispatch pool a handful of
concurrent commands starved it and froze the UI. Twenty commands sleeping
100 ms each now finish in ~100 ms rather than ~300, and 2000 concurrent
commands cost no extra platform threads where a thread pool would have created
2000 of them. Commands remain the place for blocking I/O rather than for
extended computation, which now holds a carrier thread. The color environment
still reaches command bodies, since `io-thread` conveys the binding frame too.
Where the runtime has no virtual threads - babashka's native image - it falls
back to ordinary ones.
- **`strip-ansi` strips.** It returned `AttributedString/fromAnsi`'s rendering,
which leaves a private `CSI` visible as text - `ESC[?1049h` came out as
`1049h` - and keeps carriage returns, BEL and backspace. It is now
`charm.ansi.sanitize/strip`.
- **Width measurement sanitizes first.** `string-width` and `truncate` measure
what the terminal will be given, so a sequence JLine's SGR parser mangles
rather than removes no longer throws off truncation, padding, borders, joins
and overlays.
- **`copy-to-clipboard` throws** an `ex-info` naming the size when the encoded
payload exceeds `max-clipboard-bytes` (74994, tmux's limit and the smallest
of the common ones), instead of letting the terminal truncate it silently.
- **`key-match?` accepts the short spellings** `"esc"`, `"pgup"` and `"pgdown"`
(as keywords too) for `:escape`, `:page-up` and `:page-down`. The default
page bindings in `list`, `table`, `viewport` and `paginator` now read
`"page-up"` / `"page-down"`.
- **The event loop skips the render when `update` returns the state
unchanged**, instead of re-running `view` and diffing the frame for a message
the application ignored. A resize still repaints, since the frame changed even
when the state did not.
- **Colors written as plain values now apply.** `:fg 240` (ANSI 256 code),
`:fg :red` (ANSI 16 name) and `:fg "#ff8000"` (hex string) were silently
dropped before. This also disabled the library's own default styling in
`help`, `list` and `text-input`, and most sample code.
- **Invalid color values now throw** an `ex-info` naming the value, with
`:color` in `ex-data`, instead of being ignored. This includes malformed hex
strings, so `(style/style :fg "red")` is now an error. **Potentially
breaking** for code that passed an invalid value and rendered unstyled.
- **Colors are downgraded to the terminal's profile at render time.** Under
`:ascii` color is dropped entirely.
- **Nearest-color matching is delegated to JLine's `Colors`**, which measures
distance in CIE Lab across the full 256-entry table. The previous hand-rolled
matcher searched only the color cube and the grayscale ramp, so an exact match
among the first 16 entries was unreachable — olive `(128,128,0)`, which *is*
entry 3, came back as 142 — and it quantised as though the cube levels were
evenly spaced, mapping 128 to 175 instead of 95.
- **JLine 4.3.1 → 4.4.5.** Two `ColorPalette` defects found while building the
above were fixed upstream ([#2260](https://github.com/jline/jline3/pull/2260),
[#2261](https://github.com/jline/jline3/pull/2261)). One consequence is
visible without any charm code change: output to 8- and 16-color terminals is
now downgraded as it is written, so on `TERM=xterm` a bright red `ESC[91m`
reaches the terminal as `ESC[31m`. 256-color terminals are unaffected.

### Security

- **Content a program did not author no longer reaches the terminal as
instructions.** The view is sanitized before it is written, so an escape
sequence in a filename or a log line is removed rather than obeyed. What
JLine 4.4.5 did not already stop, and this does:

| injected into displayed text | before |
|---|---|
| `ESC c` | **resets the terminal** |
| `CR` | content overwrites the line it just drew, and measures wider than it displays |
| `ESC [ ?1049h` and other private CSI | rendered as the visible text `1049h`, corrupting the frame |
| C1 controls (`0x80`-`0x9f`) | passed through |

Note for anyone reading the 0.2 threat model: on JLine 4.3.1 this list also
included OSC 52 (**writes attacker data into the system clipboard**), OSC 2
(rewrites the window title), OSC 8 (hyperlinks the text elsewhere) and DCS.
`AttributedString/fromAnsi` drops all of those as of 4.4.5, so on this release
they were already unreachable through the render path before the sanitizer -
but not through `set-window-title`, and not for anyone pinning an older JLine.

- **`set-window-title` escapes its argument.** Control characters are removed,
so a BEL or an ESC in the title can no longer end charm's OSC early and let
the rest of the string open one of its own - which was a clipboard write
reachable from any application that puts untrusted text in its title.

- **Signal handlers are restored on exit.** `run` captured neither the `WINCH`
nor the `INT` handler it displaced. Both stayed installed after the program
returned, feeding a closed channel and a closed terminal - so in a REPL, which
outlives the program, Ctrl+C was swallowed for the rest of the session and a
window resize reached a terminal that was already gone.

- **A shutdown hook restores the terminal.** `finally` does not cover
`System/exit` or `SIGTERM`, and an example in this repository exits from inside
a running program, which left raw mode on, the cursor hidden and the alternate
screen active.

- **CI supply chain.** Actions are pinned by commit SHA rather than by tag, and
the babashka installer is fetched from a release tag rather than from `master`.
(The dev build it installs is still a moving target: charm needs JLine 4.4.5,
which babashka has only there.)

### Fixed

- **Styling no longer rewrites line-drawing characters as ASCII.** JLine's
`toAnsi` both emits a style's escapes and rewrites characters it assumes the
terminal cannot show, so `(style/render (style/style :border normal :border-fg
:red) "hi")` drew `+--+` while the same border unstyled drew `┌──┐`.
Compositing had it too: placing a panel over a bordered base turned the base's
`│` and `─` into `|` and `-`.
- **Component ids cannot collide.** They were `(rand-int 1000000)`, and the
spinner and timer route their tick messages back to themselves by comparing
ids, so two components sharing one drove each other.
- **`key-match?` no longer ignores modifiers when the pattern names none.** `"c"`
also fired on Ctrl+C, so an application could not bind the two to different
things. `"ctrl+c"` is unaffected, and an uppercase letter carries no shift flag
so `"G"` still matches.
- **Truncating a styled line no longer strips its styling.** `str` on an
`AttributedString` returns the plain text, so `truncate` discarded the styling
that `columnSubSequence` had just preserved - an over-long red line came back
as bare text. This affected the renderer, viewport lines, table cells and
`style/truncate` itself. The tail stays unstyled, as before.
- **A failing terminal reader no longer spins a core.** Every exception in the
input thread was caught and discarded with no backoff. It now backs off
exponentially, and after ten consecutive failures reports an error and stops
instead of looping forever.
- **Modifiers on messages built from terminal events are booleans**, not `nil`.
`key-press` documents them as `false`, and that is what they now are however
the message was built.
- **`"esc"`, `"pgup"` and `"pgdown"` never matched anything.** Key events carry
`:escape`, `:page-up` and `:page-down`, so `key-match?` compared against
`"escape"` and friends. Page Up and Page Down were dead in `list`, `table`,
`viewport` and `paginator`, and every `"esc"` binding — including the ones in
this repository's own examples and guides — silently did nothing.
- **`:strikethrough` is applied.** It was documented and used, but never reached
`AttributedStyle`.
- **`(style :padding 3)` no longer throws.** A bare number is normalized to a
box vector, as `with-padding` already did, so both entry points agree.
- **A viewport or an overflowing view no longer copies all of its content each
frame.** Both paths called `(vec lines)` on what was already a vector before
taking a `subvec`, making a viewport over a large log O(total lines) per frame
instead of O(visible).
- **Reflective calls on render paths.** Type hints in `charm.style.overlay`
(per overlay line), `charm.render.core` and `charm.input.keymap`; padding is
built with `String.repeat` rather than a seq of characters. A new
`clojure -M:reflection` check fails the build on any reflection or
auto-boxing warning under `src/`.
- **The 16-color downgrade produced arbitrary colors.** It took `(mod code 16)`
of a 256-color cube index, which is colorimetrically meaningless — orange came
out cyan. It was unreachable until profile downgrading was wired into the
render path, which would have turned every RGB color on a `TERM=xterm`
terminal into an effectively random one of 16.

### Requirements

JDK 22+. Ships with JLine 4.4.5, which carries the palette fixes above.
Babashka with JLine 4.4.5 (currently on master) for use with babashka.
Loading
Loading