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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/decisions/index.md +12 -0
  3. data/.okf/decisions/invents-no-analysis.md +53 -0
  4. data/.okf/decisions/no-version-ceilings.md +69 -0
  5. data/.okf/decisions/okf-capability-drift.md +120 -0
  6. data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
  7. data/.okf/decisions/registry-write-boundary.md +175 -0
  8. data/.okf/decisions/ruby-floor.md +59 -0
  9. data/.okf/decisions/search-facade-coupling.md +146 -0
  10. data/.okf/decisions/undeclared-width-dependency.md +73 -0
  11. data/.okf/index.md +28 -0
  12. data/.okf/interaction/cross-bundle-scope.md +61 -0
  13. data/.okf/interaction/deferred-search.md +49 -0
  14. data/.okf/interaction/esc-peels-one-layer.md +70 -0
  15. data/.okf/interaction/filter-escalates-to-search.md +57 -0
  16. data/.okf/interaction/following-links.md +82 -0
  17. data/.okf/interaction/index.md +12 -0
  18. data/.okf/interaction/key-routing.md +84 -0
  19. data/.okf/interaction/which-registry.md +85 -0
  20. data/.okf/log.md +38 -0
  21. data/.okf/rendering/ansi-aware-width.md +74 -0
  22. data/.okf/rendering/index.md +8 -0
  23. data/.okf/rendering/markdown-rendering-trap.md +63 -0
  24. data/.okf/rendering/status-vocabulary.md +45 -0
  25. data/.okf/rendering/whole-frame-painting.md +52 -0
  26. data/.okf/testing/ci-matrix.md +80 -0
  27. data/.okf/testing/headless-frames.md +74 -0
  28. data/.okf/testing/index.md +8 -0
  29. data/.okf/testing/pty-test.md +73 -0
  30. data/CHANGELOG.md +239 -0
  31. data/LICENSE.txt +201 -0
  32. data/NOTICE +10 -0
  33. data/README.md +194 -0
  34. data/lib/okf/plugin.rb +63 -0
  35. data/lib/okf/tui/app.rb +1908 -0
  36. data/lib/okf/tui/cli.rb +154 -0
  37. data/lib/okf/tui/model.rb +410 -0
  38. data/lib/okf/tui/refs.rb +63 -0
  39. data/lib/okf/tui/ui.rb +308 -0
  40. data/lib/okf/tui/version.rb +7 -0
  41. data/lib/okf/tui/views.rb +1648 -0
  42. data/lib/okf/tui/workspace.rb +527 -0
  43. data/lib/okf/tui.rb +76 -0
  44. 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`.