okf-tui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.okf/decisions/index.md +12 -0
- data/.okf/decisions/invents-no-analysis.md +53 -0
- data/.okf/decisions/no-version-ceilings.md +69 -0
- data/.okf/decisions/okf-capability-drift.md +120 -0
- data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
- data/.okf/decisions/registry-write-boundary.md +175 -0
- data/.okf/decisions/ruby-floor.md +59 -0
- data/.okf/decisions/search-facade-coupling.md +146 -0
- data/.okf/decisions/undeclared-width-dependency.md +73 -0
- data/.okf/index.md +28 -0
- data/.okf/interaction/cross-bundle-scope.md +61 -0
- data/.okf/interaction/deferred-search.md +49 -0
- data/.okf/interaction/esc-peels-one-layer.md +70 -0
- data/.okf/interaction/filter-escalates-to-search.md +57 -0
- data/.okf/interaction/following-links.md +82 -0
- data/.okf/interaction/index.md +12 -0
- data/.okf/interaction/key-routing.md +84 -0
- data/.okf/interaction/which-registry.md +85 -0
- data/.okf/log.md +38 -0
- data/.okf/rendering/ansi-aware-width.md +74 -0
- data/.okf/rendering/index.md +8 -0
- data/.okf/rendering/markdown-rendering-trap.md +63 -0
- data/.okf/rendering/status-vocabulary.md +45 -0
- data/.okf/rendering/whole-frame-painting.md +52 -0
- data/.okf/testing/ci-matrix.md +80 -0
- data/.okf/testing/headless-frames.md +74 -0
- data/.okf/testing/index.md +8 -0
- data/.okf/testing/pty-test.md +73 -0
- data/CHANGELOG.md +239 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +194 -0
- data/lib/okf/plugin.rb +63 -0
- data/lib/okf/tui/app.rb +1908 -0
- data/lib/okf/tui/cli.rb +154 -0
- data/lib/okf/tui/model.rb +410 -0
- data/lib/okf/tui/refs.rb +63 -0
- data/lib/okf/tui/ui.rb +308 -0
- data/lib/okf/tui/version.rb +7 -0
- data/lib/okf/tui/views.rb +1648 -0
- data/lib/okf/tui/workspace.rb +527 -0
- data/lib/okf/tui.rb +76 -0
- metadata +229 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Constraint
|
|
3
|
+
title: Which Registry a Session Is On
|
|
4
|
+
description: okf resolves a project-local .okf-registry.json before the global $OKF_HOME one; the TUI did not, and being the single verb that disagreed was a silent wrong answer rather than an error.
|
|
5
|
+
tags: [registry, okf-coupling, discovery]
|
|
6
|
+
timestamp: 2026-08-13
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Overview
|
|
10
|
+
|
|
11
|
+
"Which bundles can I see?" has one right answer per directory, and okf decides it:
|
|
12
|
+
`OKF_NO_DISCOVERY` forces the global registry; otherwise a `.okf-registry.json`
|
|
13
|
+
found by walking up from the working directory wins; otherwise `$OKF_HOME`
|
|
14
|
+
(default `~/.okf`). Nearest local file wins, and a local registry stores paths
|
|
15
|
+
*relative* to itself so it can be committed and travel with the repo.
|
|
16
|
+
|
|
17
|
+
The TUI ignored all of that for a release. `Workspace` called
|
|
18
|
+
`OKF::Registry.load(home: home)` with no `cwd:`, and okf only discovers when it is
|
|
19
|
+
handed one — so inside a repo carrying a local registry, `okf registry list` and
|
|
20
|
+
`okf server` read the local file while `okf tui`, one keystroke away, read the
|
|
21
|
+
global one.
|
|
22
|
+
|
|
23
|
+
# Why it was worse than an error
|
|
24
|
+
|
|
25
|
+
Nothing failed. The TUI opened, listed bundles, and searched them. It was simply
|
|
26
|
+
answering about a *different set* than every other verb in that directory —
|
|
27
|
+
including the `okf registry list` a user would run to check what the TUI should be
|
|
28
|
+
showing. The two disagreeing is the whole failure, and neither could report it,
|
|
29
|
+
because each was internally consistent.
|
|
30
|
+
|
|
31
|
+
This is the shape [okf-capability-drift](/decisions/okf-capability-drift.md)
|
|
32
|
+
describes: okf added a resolution rule, the old call kept working, and "kept
|
|
33
|
+
working" meant "kept answering the wrong question".
|
|
34
|
+
|
|
35
|
+
# The library keeps okf's own line
|
|
36
|
+
|
|
37
|
+
`cwd:` is a parameter, not a default of `Dir.pwd`, and that mirrors okf exactly:
|
|
38
|
+
only its CLI opts in, while a library caller stays global-only. okf's reason is
|
|
39
|
+
that a discovered registry depends on where a *process happens to be*, which is
|
|
40
|
+
right for a command someone typed in a directory and wrong for an embedding app.
|
|
41
|
+
|
|
42
|
+
So `OKF::TUI::CLI` passes `cwd: Dir.pwd` and `Workspace.new` defaults to nil. The
|
|
43
|
+
suite depends on this too: with a default, `rake` would discover whatever registry
|
|
44
|
+
sat above the checkout.
|
|
45
|
+
|
|
46
|
+
Ref resolution gets discovery for free rather than separately —
|
|
47
|
+
`OKF::CLI::Command#open_registry` *is* `Registry.load(cwd: Dir.pwd)`, so a `@slug`
|
|
48
|
+
inherits the rule along with the grammar.
|
|
49
|
+
|
|
50
|
+
# Reload must reopen, not reconstruct
|
|
51
|
+
|
|
52
|
+
Every registry write is followed by a re-read, and the re-read has to be
|
|
53
|
+
`Registry#reopen`. `Registry.new(path)` drops the `relative_base` a discovered
|
|
54
|
+
local registry carries — okf hit this in its own server and wrote down the two
|
|
55
|
+
symptoms: every in-tree bundle reads as "folder is gone", and a bundle added
|
|
56
|
+
through the UI gets flattened to an absolute path, undoing the portability the
|
|
57
|
+
relative form exists for.
|
|
58
|
+
|
|
59
|
+
So `load_entries` discovers on the first load and reopens on every one after:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
@registry = (registry ? registry.reopen : open_registry) if registry_backed?
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
# The screen names the file
|
|
66
|
+
|
|
67
|
+
The header prints the registry path, and the "nothing to show" message names it
|
|
68
|
+
too. That is not decoration — it is the only way a user can tell which of the two
|
|
69
|
+
registries is in force, and it is what `refs_test.rb` asserts on to prove the CLI
|
|
70
|
+
opts into discovery at all: an empty *local* registry reports the local path,
|
|
71
|
+
where a run that ignored discovery would name the global one.
|
|
72
|
+
|
|
73
|
+
# Citations
|
|
74
|
+
|
|
75
|
+
[1] `lib/okf/tui/workspace.rb` — `open_registry`, and `registry_path` asking the
|
|
76
|
+
registry rather than recomputing from `home`.
|
|
77
|
+
[2] okf `lib/okf/registry.rb` — `Registry.load(home:, cwd:)`, `LOCAL_FILE`,
|
|
78
|
+
`NO_DISCOVERY_ENV`, and `#reopen` with the comment recording what a bare `new`
|
|
79
|
+
costs.
|
|
80
|
+
[3] okf `CHANGELOG.md` 1.12.0 — `okf registry init`, relative path storage, and the
|
|
81
|
+
hub bug that `#reopen` fixed.
|
|
82
|
+
[4] `test/integration/refs_test.rb` — the local/global pair, `OKF_NO_DISCOVERY`, and
|
|
83
|
+
the embedding-app case that must stay global-only.
|
|
84
|
+
[5] Reproduced 2026-08-13 in a scratch project: `Registry.load(cwd: pwd).path` →
|
|
85
|
+
`<project>/.okf-registry.json`, `Registry.load.path` → `<home>/registry.json`.
|
data/.okf/log.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Update Log
|
|
2
|
+
|
|
3
|
+
## 2026-08-15
|
|
4
|
+
|
|
5
|
+
* **Release**: **1.0.0**, the first. Six views over one bundle or many —
|
|
6
|
+
bundles, browse, search, graph, health, help — with the registry and its
|
|
7
|
+
groups as editable configuration, and search across every bundle in scope
|
|
8
|
+
through one shared corpus, so the scores compare between them. It
|
|
9
|
+
[invents no analysis](decisions/invents-no-analysis.md): okf owns the format,
|
|
10
|
+
the model, and every question on screen. The floor is `okf >= 2.0, < 3`, and
|
|
11
|
+
the ceiling is the one exception to
|
|
12
|
+
[no version ceilings](decisions/no-version-ceilings.md) — earned rather than
|
|
13
|
+
conventional, because an okf major is where
|
|
14
|
+
[the silent drift](decisions/okf-capability-drift.md) comes from, and a
|
|
15
|
+
renamed field read as nil is a wrong number with a green suite either side
|
|
16
|
+
of it.
|
|
17
|
+
* **Note**: **the gem ships no executable.** `okf tui` is the entry point,
|
|
18
|
+
registered through [the plugin seam](decisions/one-door-the-plugin-seam.md),
|
|
19
|
+
so installing the gem is the whole installation. A second binary that only
|
|
20
|
+
aliased the verb would be one more name to install, document and keep
|
|
21
|
+
working, and two front ends are two argument grammars that drift while each
|
|
22
|
+
passes its own tests. What ships instead is one adapter carrying argv and the
|
|
23
|
+
streams and nothing else, which `plugin_test.rb` pins by running the same
|
|
24
|
+
invocation both ways and comparing the message.
|
|
25
|
+
* **Note**: **the Rakefile sets its tag prefix behind a guard.**
|
|
26
|
+
`Bundler::GemHelper#tag_prefix=` arrived in Bundler 2.2, and the Bundler that
|
|
27
|
+
Ruby 2.4 ships is 1.17.3 — so setting it unconditionally raises
|
|
28
|
+
`NoMethodError` at Rakefile load on the floor, taking `rake test` down before
|
|
29
|
+
a single test runs. The [2.4 container](decisions/ruby-floor.md) is what sees
|
|
30
|
+
that and CI is not: `ruby/setup-ruby` installs a newer Bundler than the Ruby
|
|
31
|
+
ships. The negative branch *refuses to release* rather than installing the
|
|
32
|
+
tasks unprefixed — an old Ruby is one to test on, never one to release from,
|
|
33
|
+
and a bare `vX.Y.Z` tag here fires the Docker build for the okf image.
|
|
34
|
+
* **Creation**: the bundle seeded with 22 concepts across four areas —
|
|
35
|
+
[decisions](decisions/), [interaction](interaction/),
|
|
36
|
+
[rendering](rendering/) and [testing](testing/) — deliberately restating
|
|
37
|
+
neither the `README` (what the views answer, which keys drive them) nor
|
|
38
|
+
`AGENTS.md` (the contracts a change must keep).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Component
|
|
3
|
+
title: ANSI-aware Width
|
|
4
|
+
description: Every layout primitive measures display width on colour-stripped text, because String#length counts escape bytes and a composed frame breaks the moment those two disagree.
|
|
5
|
+
tags: [rendering, terminal, ansi]
|
|
6
|
+
timestamp: 2026-07-19
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Overview
|
|
10
|
+
|
|
11
|
+
The whole composition rests on one invariant: **every painted row measures
|
|
12
|
+
exactly the terminal width.** A row that measures wide pushes the frame's right
|
|
13
|
+
edge into the next line and the layout shears; a row that measures narrow leaves
|
|
14
|
+
the box unclosed.
|
|
15
|
+
|
|
16
|
+
Colour is what makes this hard. `"\e[31mred\e[0m"` is 3 characters on screen and
|
|
17
|
+
14 in `String#length`, so any primitive that pads, clips, or wraps using
|
|
18
|
+
`#length` is wrong the moment styling is applied — and wrong *invisibly*, because
|
|
19
|
+
the arithmetic is self-consistent.
|
|
20
|
+
|
|
21
|
+
Every primitive in `Ui` therefore measures on the ANSI-stripped text and operates
|
|
22
|
+
on the styled one:
|
|
23
|
+
|
|
24
|
+
| Primitive | Does |
|
|
25
|
+
|-----------|------|
|
|
26
|
+
| `Line` | builds a row, tracking `@spent` in display columns |
|
|
27
|
+
| `clip_ansi` | truncates to N columns without cutting an escape in half |
|
|
28
|
+
| `wrap_ansi` | wraps at display width, carrying style across the break |
|
|
29
|
+
| `reflow` | rejoins paragraphs, then wraps them |
|
|
30
|
+
| `fit_block` | pads **and truncates** a block to exact height and width |
|
|
31
|
+
|
|
32
|
+
# The test has to run twice
|
|
33
|
+
|
|
34
|
+
Pastel disables colour when stdout is not a terminal. So a captured frame in a
|
|
35
|
+
test is **uncoloured**, which exercises none of the code above — the geometry
|
|
36
|
+
suite would pass while every coloured path was broken.
|
|
37
|
+
|
|
38
|
+
`GeometryTest` therefore runs the whole matrix twice, swapping the `PASTEL`
|
|
39
|
+
constant for a `Pastel.new(enabled: true)` instance on the second pass: 20
|
|
40
|
+
interaction states × 4 terminal sizes × 2 colour modes. That doubling is not
|
|
41
|
+
thoroughness for its own sake; it is the only pass that tests the ANSI
|
|
42
|
+
arithmetic at all.
|
|
43
|
+
|
|
44
|
+
`fit_block` originally padded but never truncated, which is precisely the bug
|
|
45
|
+
this catches: markdown longer than the pane overflowed and wrapped the frame.
|
|
46
|
+
|
|
47
|
+
The invariant exists because nothing
|
|
48
|
+
[repairs a bad row later](/rendering/whole-frame-painting.md) — the frame is
|
|
49
|
+
printed whole and then not touched until the next keypress. And the doubling is
|
|
50
|
+
not paranoia: a bug that lives *only* in the coloured path is exactly what
|
|
51
|
+
[the tty-markdown trap](/rendering/markdown-rendering-trap.md) turned out to be.
|
|
52
|
+
|
|
53
|
+
# And a third time, on text where a character is not a column
|
|
54
|
+
|
|
55
|
+
The matrix above asserts each row's *stripped length* equals the width — which
|
|
56
|
+
is only a width assertion on ASCII, where characters and columns are the same
|
|
57
|
+
number. Every fixture was ASCII, so all 6,240 of those assertions passed
|
|
58
|
+
identically whether the layout measured columns or characters. They could not
|
|
59
|
+
tell the two apart.
|
|
60
|
+
|
|
61
|
+
The `wide` fixture is the case that can: CJK is two columns per character, emoji
|
|
62
|
+
two, a combining mark zero. Six states render it at the same four sizes in both
|
|
63
|
+
colour modes, measured with `Unicode::DisplayWidth` **directly** rather than
|
|
64
|
+
through `Ui.width`, so a regression in `Ui` cannot be validated by a test using
|
|
65
|
+
the same broken measure.
|
|
66
|
+
|
|
67
|
+
Which matters because `Ui.width` depends on a gem nobody declared — see
|
|
68
|
+
[the undeclared width dependency](/decisions/undeclared-width-dependency.md).
|
|
69
|
+
|
|
70
|
+
# Citations
|
|
71
|
+
|
|
72
|
+
[1] `lib/okf/tui/ui.rb` — the primitives.
|
|
73
|
+
[2] `test/integration/geometry_test.rb` — the two-colour matrix and
|
|
74
|
+
`with_colour`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Rendering
|
|
2
|
+
|
|
3
|
+
How a frame is composed, and the arithmetic that breaks when colour is involved.
|
|
4
|
+
|
|
5
|
+
* [ANSI-aware Width](ansi-aware-width.md) - Display width versus `String#length`, the primitives that respect it, and why the geometry suite runs twice.
|
|
6
|
+
* [Whole-frame Painting](whole-frame-painting.md) - Repaint everything each keystroke; the purity that buys, and the constraints it imposes.
|
|
7
|
+
* [The tty-markdown Wrapping Trap](markdown-rendering-trap.md) - An `IndexError` that only appears with colour on, and the parse width that avoids it.
|
|
8
|
+
* [One Verdict, Worn Everywhere](status-vocabulary.md) - Collapsing `validate` and `lint` into one colour so a problem is visible from any view.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Reference
|
|
3
|
+
title: The tty-markdown Wrapping Trap
|
|
4
|
+
description: tty-markdown raises IndexError on some documents when colour is on, so the renderer asks it never to wrap and does its own wrapping instead.
|
|
5
|
+
tags: [rendering, terminal, ansi, scar-tissue]
|
|
6
|
+
timestamp: 2026-07-18
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# The symptom
|
|
10
|
+
|
|
11
|
+
Some concepts rendered as a red error instead of a body. Not all of them, and not
|
|
12
|
+
reproducibly from a test or a screenshot — 50 of 192 concept/width pairs failed.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
IndexError: index N out of string
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
# The cause
|
|
19
|
+
|
|
20
|
+
`tty-markdown` wraps text through the `strings` gem, which miscounts ANSI escape
|
|
21
|
+
sequences and can compute an insert position past the end of the string. It only
|
|
22
|
+
happens **with colour on**, which is why nothing caught it: Pastel disables
|
|
23
|
+
colour when stdout is not a terminal, so every piped test run and every captured
|
|
24
|
+
screenshot rendered the same documents fine. The bug lived exclusively in the
|
|
25
|
+
interactive path.
|
|
26
|
+
|
|
27
|
+
# The fix
|
|
28
|
+
|
|
29
|
+
Take the wrapping away from tty-markdown entirely. It is asked to parse at a
|
|
30
|
+
width no document reaches, and the layout's own
|
|
31
|
+
[ANSI-aware wrapping](/rendering/ansi-aware-width.md) does the real work
|
|
32
|
+
afterwards:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
PARSE_WIDTH = 10_000
|
|
36
|
+
|
|
37
|
+
mode = Ui.pastel.enabled? ? :always : :never
|
|
38
|
+
Ui.reflow(TTY::Markdown.parse(source, width: PARSE_WIDTH, color: mode).lines, limit)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Two details matter and neither is decoration:
|
|
42
|
+
|
|
43
|
+
- **`PARSE_WIDTH = 10_000`** — large enough that tty-markdown never reaches a
|
|
44
|
+
wrap decision, so the miscounting code never runs.
|
|
45
|
+
- **explicit `color:`** — tty-markdown otherwise makes its own colour decision,
|
|
46
|
+
which can disagree with Pastel's and produce escapes the layout did not expect.
|
|
47
|
+
|
|
48
|
+
The rescue around the render now includes the exception *message*, not just the
|
|
49
|
+
class. The original error surfaced as a bare class name, which said nothing about
|
|
50
|
+
where to look.
|
|
51
|
+
|
|
52
|
+
# The lesson worth keeping
|
|
53
|
+
|
|
54
|
+
A rendering bug that only appears with colour on cannot be caught by any test
|
|
55
|
+
that captures output through a pipe. When a screen misbehaves in the terminal but
|
|
56
|
+
not in a test, **suspect the colour path first** — it is the one the harness
|
|
57
|
+
never walks.
|
|
58
|
+
|
|
59
|
+
# Citations
|
|
60
|
+
|
|
61
|
+
[1] `lib/okf/tui/app.rb` — `PARSE_WIDTH` and the render.
|
|
62
|
+
[2] Reproduced by sweeping every concept × width with colour forced on: 50/192
|
|
63
|
+
raised `IndexError`; 0/192 with the fix.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Concept
|
|
3
|
+
title: One Verdict, Worn Everywhere
|
|
4
|
+
description: A bundle is clean, warned, or not conformant, and that single judgement drives its colour in every place it is named — so a problem is visible without opening the health view.
|
|
5
|
+
tags: [rendering, ux]
|
|
6
|
+
timestamp: 2026-07-18
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Overview
|
|
10
|
+
|
|
11
|
+
`validate` and `lint` answer different questions and the health view keeps them
|
|
12
|
+
in separate sections, as [okf requires](/decisions/invents-no-analysis.md). But a
|
|
13
|
+
*reader* needs one thing at a glance: is this bundle all right?
|
|
14
|
+
|
|
15
|
+
So the two outputs collapse into one presentational verdict:
|
|
16
|
+
|
|
17
|
+
| Verdict | Source | Colour |
|
|
18
|
+
|---------|--------|--------|
|
|
19
|
+
| not conformant | `validate` has §9 errors | red |
|
|
20
|
+
| warnings | `validate` is clean, `lint` has findings | orange |
|
|
21
|
+
| clean | both clean | default |
|
|
22
|
+
|
|
23
|
+
This is a *rendering* of okf's analysis, not a fourth judgement — no check is
|
|
24
|
+
computed here.
|
|
25
|
+
|
|
26
|
+
# Why it appears everywhere
|
|
27
|
+
|
|
28
|
+
The verdict follows the bundle's name into every place the name appears: the
|
|
29
|
+
header, the footer badge, its row in the registry list, its detail pane, and the
|
|
30
|
+
health tab's own label.
|
|
31
|
+
|
|
32
|
+
The health tab carrying it is the point of the design. Without it a reader has to
|
|
33
|
+
*visit* the health view to learn there is anything to see, which means the
|
|
34
|
+
common case — nothing wrong — costs a trip, and the uncommon case is invisible
|
|
35
|
+
until you happen to look. Colouring the tab makes the bundle's state ambient:
|
|
36
|
+
you find out from wherever you already are.
|
|
37
|
+
|
|
38
|
+
Because the colour is attached to the name rather than to a view, switching the
|
|
39
|
+
active bundle changes it everywhere at once, which is what makes it readable
|
|
40
|
+
while moving between bundles — see
|
|
41
|
+
[cross-bundle-scope](/interaction/cross-bundle-scope.md).
|
|
42
|
+
|
|
43
|
+
# Citations
|
|
44
|
+
|
|
45
|
+
[1] `lib/okf/tui/views.rb` — `health_status`, `STATUS`, `status_style`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Component
|
|
3
|
+
title: Whole-frame Painting
|
|
4
|
+
description: Each keystroke repaints every row from cursor-home rather than diffing, which makes a frame a pure function of state and is what lets the tests render without a terminal.
|
|
5
|
+
tags: [rendering, terminal, testing]
|
|
6
|
+
timestamp: 2026-07-18
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Overview
|
|
10
|
+
|
|
11
|
+
There is no damage tracking, no dirty-region diffing, and no partial redraw.
|
|
12
|
+
`paint` moves the cursor home and prints exactly `height` rows, every time.
|
|
13
|
+
|
|
14
|
+
For a screen of this size the redraw is imperceptible, and buying simplicity with
|
|
15
|
+
it is a good trade — but the real payoff is not performance, it is testability.
|
|
16
|
+
|
|
17
|
+
# Why it makes the UI testable
|
|
18
|
+
|
|
19
|
+
Because painting reads state and writes a string, a frame is a **pure function of
|
|
20
|
+
(workspace, keys, size)**. Nothing about it needs a terminal:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
app = App.new(dirs: [...], output: StringIO.new)
|
|
24
|
+
FixedScreen.with(width, height) { keys.each { |key| app.handle(key) } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`handle` mutates state exactly as the key loop does, and `paint` renders it into
|
|
28
|
+
a `StringIO`. So the tests drive real interactions and assert on real frames
|
|
29
|
+
without a pty — see [headless-frames](/testing/headless-frames.md). A diffing
|
|
30
|
+
renderer would have made the output depend on what was on screen *before*, and
|
|
31
|
+
that property would be gone.
|
|
32
|
+
|
|
33
|
+
The terminal size is the only ambient input, so `FixedScreen` pins it by
|
|
34
|
+
prepending an override onto `TTY::Screen` — otherwise a frame would render
|
|
35
|
+
differently on the machine running the suite.
|
|
36
|
+
|
|
37
|
+
# The consequences to respect
|
|
38
|
+
|
|
39
|
+
- **Every row must be exactly the width** — nothing repairs a short row on the
|
|
40
|
+
next pass, because there is no next pass until a key arrives. See
|
|
41
|
+
[ansi-aware-width](/rendering/ansi-aware-width.md).
|
|
42
|
+
- **The frame must fit the height** — a view that builds more rows than the
|
|
43
|
+
terminal has scrolls its own content; it never lets the frame overflow. Health,
|
|
44
|
+
graph and help each build the full page and scroll it, which replaced an
|
|
45
|
+
earlier per-pane budget that could go negative on a short terminal and crash.
|
|
46
|
+
- **Views stay pure row builders.** No view writes to the terminal; they return
|
|
47
|
+
arrays of rows. The app is the only thing that prints.
|
|
48
|
+
|
|
49
|
+
# Citations
|
|
50
|
+
|
|
51
|
+
[1] `lib/okf/tui/app.rb` — `paint`.
|
|
52
|
+
[2] `test/test_helper.rb` — `FixedScreen`, `frame_for`, `render`.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Runbook
|
|
3
|
+
title: The CI Matrix and What Only It Catches
|
|
4
|
+
description: Ten Rubies on every push, what only the 2.4 container proves, and the resolution gap the matrix cannot see because every run resolves the sibling checkout.
|
|
5
|
+
tags: [testing, ruby-floor, dependencies]
|
|
6
|
+
timestamp: 2026-07-19
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# The matrix
|
|
10
|
+
|
|
11
|
+
The repository's `.github/workflows/main.yml` runs this gem's default task on
|
|
12
|
+
Ruby 2.4 through 4.0 as its own `okf-tui` job — 2.4/2.5/2.6 on `ubuntu-22.04`,
|
|
13
|
+
the rest on `ubuntu-latest`, since the older Rubies predate the current image's
|
|
14
|
+
toolchain. `fail-fast: false`, so one failure does not hide the others. It is one
|
|
15
|
+
job per gem rather than a gem axis on one matrix, because the gems in the
|
|
16
|
+
repository do not share a Ruby floor. A change is not done until the matrix is
|
|
17
|
+
green.
|
|
18
|
+
|
|
19
|
+
The floor can also be proven locally, which is faster than pushing. Run it from
|
|
20
|
+
the repository root and let it step in here — the Gemfile resolves okf from
|
|
21
|
+
`../okf`, so a container holding only this directory fails at `bundle install`
|
|
22
|
+
before a test runs:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
docker run --rm -v "$PWD":/src:ro ruby:2.4 bash -c \
|
|
26
|
+
"cp -a /src /build && cd /build/okf-tui && rm -f Gemfile.lock && bundle install --quiet && bundle exec rake test"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The copy and the dropped lockfile are both load-bearing: a lockfile written by a
|
|
30
|
+
modern Bundler is one 2.4's own cannot read, and mounting read-only stops the run
|
|
31
|
+
writing one back.
|
|
32
|
+
|
|
33
|
+
**This container runs an older Bundler than CI does, and that is a feature.**
|
|
34
|
+
`ruby/setup-ruby` installs the newest Bundler each Ruby accepts; the image ships
|
|
35
|
+
the one the Ruby came with — 1.17.3 on 2.4. The gap is a real support claim, and
|
|
36
|
+
it caught a Rakefile that could not *load* there: `GemHelper#tag_prefix=` arrived
|
|
37
|
+
in Bundler 2.2, so the line that keeps this gem's release tags prefixed raised
|
|
38
|
+
`NoMethodError` before `rake test` reached a test. The matrix would have been
|
|
39
|
+
green over it.
|
|
40
|
+
|
|
41
|
+
# What the matrix cannot catch
|
|
42
|
+
|
|
43
|
+
A green run proves the code against the okf on this disk, not the okf a user
|
|
44
|
+
gets: the Gemfile resolves the kernel from the checkout next door, so every run
|
|
45
|
+
— local, CI, the floor container — exercises the *unreleased* kernel, and
|
|
46
|
+
nothing crosses to RubyGems by default. When two runs of identical code
|
|
47
|
+
disagree, suspect dependency resolution before suspecting the runner.
|
|
48
|
+
|
|
49
|
+
Two things stand in that gap, and they cover different halves:
|
|
50
|
+
|
|
51
|
+
- `test/unit/gemspec_test.rb` fails the moment okf bumps and the gemspec's
|
|
52
|
+
declared floor does not follow, so the floor can never quietly come to admit a
|
|
53
|
+
kernel this code has outgrown.
|
|
54
|
+
- A scripted run against the *published* okf catches what a floor cannot
|
|
55
|
+
express, which is that a released kernel returns different analysis output:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
sed '/gem "okf", path:/d' Gemfile > Gemfile.ci-check
|
|
59
|
+
BUNDLE_GEMFILE=Gemfile.ci-check bundle install && BUNDLE_GEMFILE=Gemfile.ci-check bundle exec rake
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run it before pushing anything that reads okf's analysis, and before a release.
|
|
63
|
+
Nothing enforces it, which is the honest state: it is a maintainer obligation,
|
|
64
|
+
and saying so is better than believing the matrix covers it.
|
|
65
|
+
|
|
66
|
+
Two details keep that reproduction honest:
|
|
67
|
+
|
|
68
|
+
- **`Gemfile.lock` is gitignored**, so there is no committed resolution to mask
|
|
69
|
+
a broken one — every run re-resolves.
|
|
70
|
+
- **A plain `cp` of the tree keeps the untracked lockfile**, and that lockfile
|
|
71
|
+
can carry a `PATH` remote naming an absolute directory on this machine — so a
|
|
72
|
+
copy made to test the published resolution can quietly resolve against
|
|
73
|
+
something no user has. Drop the lockfile in the copy, exactly as the Docker
|
|
74
|
+
command above drops it for its own reason (a lockfile written by a modern
|
|
75
|
+
Bundler is one 2.4's cannot read).
|
|
76
|
+
|
|
77
|
+
# Citations
|
|
78
|
+
|
|
79
|
+
[1] The repository's `.github/workflows/main.yml`, `okf-tui` job.
|
|
80
|
+
[2] `AGENTS.md` — the scripted `Gemfile.ci-check` run against the published okf.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Playbook
|
|
3
|
+
title: Testing Frames Without a Terminal
|
|
4
|
+
description: How the suite drives real interactions and asserts on real frames with no pty — and the discipline of proving each check can actually fail.
|
|
5
|
+
tags: [testing, rendering]
|
|
6
|
+
timestamp: 2026-07-19
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# The shape
|
|
10
|
+
|
|
11
|
+
Because [painting is a pure function of state](/rendering/whole-frame-painting.md),
|
|
12
|
+
a test needs no terminal — only a key script and a size:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
render(home: home, keys: "4<enter><tab>", size: [ 100, 30 ])
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`keystrokes` maps `<enter>`, `<tab>`, `<esc>`, `<down>` to the real bytes, so a
|
|
19
|
+
script reads as what a user actually pressed. `app_for` drives `handle`;
|
|
20
|
+
`frame_for` renders through a `StringIO`; `FixedScreen` pins the size.
|
|
21
|
+
|
|
22
|
+
Bundles come from `with_registry`, which builds a temporary `$OKF_HOME`. Nothing
|
|
23
|
+
in the suite ever touches the real `~/.okf` — that is the user's configuration,
|
|
24
|
+
not the suite's.
|
|
25
|
+
|
|
26
|
+
# Prefer naming over counting
|
|
27
|
+
|
|
28
|
+
`app.open_concept("overview")` rather than three `<down>` presses. The list holds
|
|
29
|
+
reserved files as well as concepts, so cursor arithmetic is a guess about
|
|
30
|
+
ordering rather than a statement about which concept is open — and it breaks
|
|
31
|
+
whenever a fixture gains a file, in a way that looks like a real failure.
|
|
32
|
+
|
|
33
|
+
# Prove the check can fail
|
|
34
|
+
|
|
35
|
+
The discipline that mattered most here, because it repeatedly caught checks that
|
|
36
|
+
could not have failed:
|
|
37
|
+
|
|
38
|
+
- a geometry check reported "ok" on a frame that had **crashed** and rendered
|
|
39
|
+
empty — zero rows trivially satisfied "every row is the right width";
|
|
40
|
+
- a check looped over the very constant it was testing, so it agreed with itself;
|
|
41
|
+
- a scroll check judged an offset against a different window than the view used,
|
|
42
|
+
and reported a failure that was not real.
|
|
43
|
+
|
|
44
|
+
So: **sabotage the code, watch the check fail for the reason you predicted, then
|
|
45
|
+
restore it.** A green check that has never been seen red is an assumption wearing
|
|
46
|
+
a test's clothes.
|
|
47
|
+
|
|
48
|
+
The sharpest case was a whole suite that could not fail: 6,240 geometry
|
|
49
|
+
assertions that passed identically whether the layout measured columns or
|
|
50
|
+
characters, because every fixture was ASCII and the two numbers agree there.
|
|
51
|
+
Reducing `Ui.width` to `plain.length` was the sabotage that proved it — the
|
|
52
|
+
suite stayed green, and only a fixture of
|
|
53
|
+
[wide characters](/decisions/undeclared-width-dependency.md) turned it red. The same rule as the repo's test-first discipline, applied to
|
|
54
|
+
the harness itself — a bug report earns a red test before it earns a patch, and
|
|
55
|
+
the red has to be for the predicted reason, not a missing fixture or a typo'd
|
|
56
|
+
regex.
|
|
57
|
+
|
|
58
|
+
Assertions must also be read off real output: run it, read what it actually
|
|
59
|
+
prints, then assert *that*. Asserting what you assume the code does is how a
|
|
60
|
+
green suite certifies a bug.
|
|
61
|
+
|
|
62
|
+
# What this layer cannot catch
|
|
63
|
+
|
|
64
|
+
Two things, each covered elsewhere because no headless frame can reach them:
|
|
65
|
+
|
|
66
|
+
- a broken key loop, a raw-mode failure, or a binary that will not boot — that is
|
|
67
|
+
[the pty test](/testing/pty-test.md);
|
|
68
|
+
- anything that depends on which okf actually resolves, since the Gemfile prefers
|
|
69
|
+
the sibling checkout locally — that is [the CI matrix](/testing/ci-matrix.md).
|
|
70
|
+
|
|
71
|
+
# Citations
|
|
72
|
+
|
|
73
|
+
[1] `test/test_helper.rb` — `FixedScreen`, `with_registry`, `app_for`,
|
|
74
|
+
`frame_for`, `keystrokes`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
How a full-screen terminal app is proven, and what each layer can and cannot
|
|
4
|
+
catch.
|
|
5
|
+
|
|
6
|
+
* [Testing Frames Without a Terminal](headless-frames.md) - Driving real interactions headlessly, and the discipline of proving a check can fail.
|
|
7
|
+
* [The One Test That Opens a Terminal](pty-test.md) - The single pty walk, and the three timing traps that made it flake on the floor.
|
|
8
|
+
* [The CI Matrix and What Only It Catches](ci-matrix.md) - Ten Rubies, the Docker floor check, and the dependency bug a local run cannot see.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Runbook
|
|
3
|
+
title: The One Test That Opens a Terminal
|
|
4
|
+
description: A single pty test boots `okf tui` in a real process and walks every view, and the three timing traps that made it flake on the Ruby floor.
|
|
5
|
+
tags: [testing, terminal, scar-tissue]
|
|
6
|
+
timestamp: 2026-07-18
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Why it exists
|
|
10
|
+
|
|
11
|
+
Every other test calls `App#handle` directly and never opens a terminal, so none
|
|
12
|
+
of them can catch a broken key loop, a raw-mode failure, or a verb that will not
|
|
13
|
+
boot. This one spawns a real process through a real pty, sends real keypresses,
|
|
14
|
+
walks all six views, quits on `q`, and asserts the exit status — plus that the
|
|
15
|
+
registry file is byte-identical afterwards.
|
|
16
|
+
|
|
17
|
+
**It spawns okf's executable, not one of this gem's, because this gem has none**
|
|
18
|
+
— the [plugin seam](/decisions/one-door-the-plugin-seam.md) is the only entry
|
|
19
|
+
point. That is not a workaround; it is what makes this test cover more than it
|
|
20
|
+
used to. It is now the only place the *whole* path a user walks is exercised:
|
|
21
|
+
okf boots, misses `tui` among its built-ins, discovers `okf/plugin.rb` on the
|
|
22
|
+
load path, registers the verb, and hands it a real terminal. Every other plugin
|
|
23
|
+
test drives the dispatcher in-process, which skips process boot and discovery
|
|
24
|
+
both.
|
|
25
|
+
|
|
26
|
+
The executable is asked of RubyGems (`Gem.bin_path("okf", "okf")`) rather than
|
|
27
|
+
guessed from a relative path: okf is a path source inside the monorepo and an
|
|
28
|
+
ordinary gem outside it, and this has to keep working either way.
|
|
29
|
+
|
|
30
|
+
It is deliberately *one* test. A pty is slow and timing-dependent; the cheap
|
|
31
|
+
[headless frames](/testing/headless-frames.md) carry the coverage, and this
|
|
32
|
+
carries the proof that the thing boots and runs at all.
|
|
33
|
+
|
|
34
|
+
# The three traps
|
|
35
|
+
|
|
36
|
+
Each of these was a real failure on the Ruby 2.4 floor, and each looks like a
|
|
37
|
+
different bug than it is:
|
|
38
|
+
|
|
39
|
+
**No `$TERM`.** `tty-cursor` shells out to `tput`. A container or CI runner
|
|
40
|
+
without `TERM` set paints *nothing at all* — which reads as a hung app, not a
|
|
41
|
+
missing environment variable. The spawn passes `"TERM" => "xterm"`.
|
|
42
|
+
|
|
43
|
+
**An unsized pty.** `TTY::Screen` asks the pty itself before it reads `LINES`
|
|
44
|
+
and `COLUMNS`, and an unsized pty reports nothing, so the app paints an empty
|
|
45
|
+
frame. `reader.winsize = [ 40, 120 ]` after spawn, not just the env vars.
|
|
46
|
+
|
|
47
|
+
# The spawn's environment is the isolation
|
|
48
|
+
|
|
49
|
+
The child gets `OKF_HOME` named explicitly, alongside `TERM`/`LINES`/`COLUMNS`.
|
|
50
|
+
That is not tidiness: since [`--home` was dropped](/decisions/one-door-the-plugin-seam.md)
|
|
51
|
+
it is the **only** lever on which registry the binary reads, and this is the one
|
|
52
|
+
test that runs a real process against a real registry file. Naming it rather
|
|
53
|
+
than trusting inheritance is what keeps the run off the user's own `~/.okf` —
|
|
54
|
+
the thing the byte-identical assertion above would otherwise be checking on
|
|
55
|
+
*their* file.
|
|
56
|
+
|
|
57
|
+
**Settling on the first byte.** The app hides the cursor *before* it does any
|
|
58
|
+
work, so the first bytes arrive immediately while the real boot — Bundler setup,
|
|
59
|
+
gem loads, reading every bundle — happens after them. A settle that starts its
|
|
60
|
+
short idle timer on the first byte therefore declares an app "settled" before it
|
|
61
|
+
has painted, which is exactly what happened on 2.4. The wait is generous until
|
|
62
|
+
the buffer contains a `\n`, and short after: a painted frame always has newlines,
|
|
63
|
+
the escape sequences before it do not.
|
|
64
|
+
|
|
65
|
+
# Send one control key per step
|
|
66
|
+
|
|
67
|
+
`"\t\r"` in a single write can reach the reader as a *single* keypress. Split
|
|
68
|
+
control keys into separate steps — a race the test loses only sometimes is worse
|
|
69
|
+
than one it loses always.
|
|
70
|
+
|
|
71
|
+
# Citations
|
|
72
|
+
|
|
73
|
+
[1] `test/integration/terminal_test.rb` — `SCRIPT`, `settle`, `reap`.
|