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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: def7a9aa9c4d6fd614896848e6e6712491f062f830d487e260142bfdd8698374
4
+ data.tar.gz: 96e3fb4d8bdbb6c78aef7cf00392372667d89066793bd81feacb6e486e13967a
5
+ SHA512:
6
+ metadata.gz: 9520521f0c1ca38385d2ab974b81d1f574923be0ae9f4612c4dd590d66d11a92dbc0e88ac57d81793944134b349df664e802d102609d24d899bb7bf577aeb383
7
+ data.tar.gz: f8f24b77abed3ef919c40ff34d322ff81d006be77f27c2daf1bb1377bc1634d8834eaa80cd4d93ad987f57a13bd08bce499166a01ebb04578cc6874ed05d5223
@@ -0,0 +1,12 @@
1
+ # Decisions
2
+
3
+ The choices that shape okf-tui, each with the tradeoff it accepted.
4
+
5
+ * [It Invents No Analysis](invents-no-analysis.md) - Every judgement comes from okf; the negative rule this imposes on any feature request, and what it costs.
6
+ * [The Search Facade Coupling](search-facade-coupling.md) - Why search rides Bundle::Search.across, and why that needs a check at boot rather than a rescue.
7
+ * [Ruby 2.4 Floor, Inherited](ruby-floor.md) - The floor taken from okf, the 2.5 API that slipped past RuboCop, and why the linter loads conditionally.
8
+ * [No Version Ceilings on the Markdown Stack](no-version-ceilings.md) - Pinning kramdown and rouge down to protect the floor broke every Ruby above it.
9
+ * [One Door — the Plugin Seam Is the Entry Point](one-door-the-plugin-seam.md) - okf-tui ships no executable and registers as an `okf` command; why one front end rather than two, and why registration must not build anything.
10
+ * [The Undeclared Width Dependency](undeclared-width-dependency.md) - Column measurement rests on a gem that arrives through tty-box; why that is accepted, and what fails loudly if it stops.
11
+ * [okf Moves, and This Breaks Quietly](okf-capability-drift.md) - Inventing no analysis means okf's evolution is this program's; every drift found so far failed silently, and agreement tests are the only defence that works.
12
+ * [It Writes the Registry, Never a Bundle](registry-write-boundary.md) - The line under the TUI's side effects is the registry versus the knowledge — what that admits, and why `registry init` falls outside it.
@@ -0,0 +1,53 @@
1
+ ---
2
+ type: Decision
3
+ title: It Invents No Analysis
4
+ description: Every judgement on screen is a pure call into okf; the TUI adds no check, score, or verdict of its own, which bounds what a feature request may be answered with.
5
+ tags: [okf-coupling, dependencies]
6
+ timestamp: 2026-07-19
7
+ ---
8
+
9
+ # Overview
10
+
11
+ `OKF::Bundle::Reader` and `OKF::Registry` are the only parts that touch disk, and
12
+ everything drawn is a pure call on the resulting in-memory bundles — `catalog`,
13
+ `graph`, `validate`, `lint`, `Bundle::Search`. The TUI is one more shell over the
14
+ same core the `okf` CLI and the graph server already use.
15
+
16
+ The rule this actually imposes is a *negative* one, and it is the reason to write
17
+ it down: **a question the TUI cannot answer by asking okf is a question it does
18
+ not answer.** No extra lint check, no derived score, no "helpful" verdict
19
+ computed locally. If a screen wants an analysis okf does not expose, the change
20
+ belongs in okf.
21
+
22
+ # What that keeps intact
23
+
24
+ okf's own contracts survive the port to a screen, rather than being softened for
25
+ presentation:
26
+
27
+ | okf contract | How the screen keeps it |
28
+ |--------------|------------------------|
29
+ | `validate` and `lint` answer different questions | separate sections in the health view, never merged into one score |
30
+ | reads are best-effort | an unreadable file reports as `⊘ n unreadable`, not fatal |
31
+ | exit codes mean something | a bad directory exits `2`, the usage-error code |
32
+
33
+ The one place the TUI *does* add vocabulary is presentational: the clean /
34
+ warnings / not-conformant verdict that colours the bundle name everywhere it
35
+ appears. That is a rendering of `validate` + `lint` output, not a fourth
36
+ judgement — see [status-vocabulary](/rendering/status-vocabulary.md).
37
+
38
+ # The cost
39
+
40
+ It makes okf-tui's release calendar depend on okf's. The search view demonstrated
41
+ the sharp edge: [search-facade-coupling](/decisions/search-facade-coupling.md)
42
+ was blocked entirely on an okf release, with no local workaround *by
43
+ construction* — implementing a fallback search here would have broken this rule.
44
+
45
+ That one has since resolved (okf 1.9.0 shipped the facade), which is the point
46
+ rather than a footnote: the cost of this rule is paid in **waiting**, not in
47
+ capability. The wait ended, and nothing had to be built twice.
48
+
49
+ # Citations
50
+
51
+ [1] `lib/okf/tui.rb` — the module comment stating the split.
52
+ [2] `lib/okf/tui/model.rb`, `lib/okf/tui/workspace.rb` — every analysis is a
53
+ memoized call into okf.
@@ -0,0 +1,69 @@
1
+ ---
2
+ type: Decision
3
+ title: No Version Ceilings on the Markdown Stack
4
+ description: Pinning kramdown and rouge to their old lines to protect the Ruby 2.4 floor broke modern Ruby instead; the gems declare their own floors, so resolution handles it per-Ruby.
5
+ tags: [ruby-floor, dependencies]
6
+ timestamp: 2026-07-18
7
+ ---
8
+
9
+ # Overview
10
+
11
+ `tty-markdown` pulls in `kramdown` and `rouge`. Both have dropped Ruby 2.4 in
12
+ their current lines, so the first instinct was to pin everyone to the last
13
+ versions that still ran on the floor:
14
+
15
+ ```ruby
16
+ spec.add_dependency "kramdown", ">= 2.3", "< 2.5" # wrong
17
+ spec.add_dependency "rouge", ">= 3.14", "< 4.0" # wrong
18
+ ```
19
+
20
+ That is backwards, and it broke the *modern* end of the matrix: kramdown 2.4
21
+ calls a `CGI` method that no longer exists on Ruby 4.0, so every job above the
22
+ floor died with `NoMethodError: undefined method 'parse' for class CGI`.
23
+
24
+ # The rule
25
+
26
+ **Do not pin a dependency down to protect an old Ruby.** A ceiling is a claim
27
+ about every Ruby, made to serve one of them.
28
+
29
+ The gems already carry `required_ruby_version`, and Bundler honours it: on 2.4 it
30
+ resolves kramdown 2.4.0 and rouge 3.30.0 by itself, on 4.0 it resolves the
31
+ current lines. Declaring only floors lets each Ruby get the newest version that
32
+ actually runs there — which is more than the ceiling would have allowed, and
33
+ correct on every Ruby rather than one.
34
+
35
+ ```ruby
36
+ spec.add_dependency "kramdown", ">= 2.3"
37
+ spec.add_dependency "rouge", ">= 3.14"
38
+ ```
39
+
40
+ This is the same reasoning that gates the *development* tooling instead of
41
+ pinning it — see [ruby-floor](/decisions/ruby-floor.md), where RuboCop and irb
42
+ load only on 2.7+ because they cannot install on the floor at all.
43
+
44
+ # The one ceiling, and what earns it
45
+
46
+ `okf` carries `< 3`, and it is not a hedge against a Ruby — it is the exception
47
+ that shows what the rule is actually about. The rule refuses a ceiling *made to
48
+ serve one Ruby*, because `required_ruby_version` already expresses that and
49
+ better. It does not refuse a ceiling that expresses a real incompatibility
50
+ resolution cannot see.
51
+
52
+ This one does, and the evidence is this gem's own scar tissue: an okf *major* is
53
+ where the renames come from, and those renames break screens
54
+ [silently](/decisions/okf-capability-drift.md) — `area` became `top_dir` and
55
+ every bundle reported one directory; `timestamp` became `generated_at` and the
56
+ "updated" row stopped rendering. Neither raised. Neither turned a suite red.
57
+
58
+ So `< 3` buys the one thing a floor cannot: it turns the *next* one into a
59
+ resolution failure a maintainer reads, instead of a wrong number a reader
60
+ believes. `test/unit/gemspec_test.rb` pins both bounds — the floor may never lag
61
+ the kernel this gem develops against, and the ceiling may not quietly go away.
62
+
63
+ # Citations
64
+
65
+ [1] `okf-tui.gemspec` — the two floors, with the comment recording the failed
66
+ ceiling attempt.
67
+ [2] Verified in `ruby:2.4` (Docker): resolved kramdown 2.4.0 + rouge 3.30.0,
68
+ 34 runs / 6622 assertions green, and green across
69
+ [the whole matrix](/testing/ci-matrix.md) up to 4.0.
@@ -0,0 +1,120 @@
1
+ ---
2
+ type: Decision
3
+ title: okf Moves, and This Breaks Quietly
4
+ description: Inventing no analysis means every answer on screen is okf's — so okf renaming a field or adding a faster path is a change to this program, and every such drift found so far failed silently rather than loudly.
5
+ tags: [okf-coupling, dependencies, testing, maintenance]
6
+ timestamp: 2026-08-13
7
+ ---
8
+
9
+ # Overview
10
+
11
+ [invents-no-analysis](/decisions/invents-no-analysis.md) says every judgement on
12
+ screen comes from okf. The cost of that rule is not the one it looks like. It is
13
+ not that features are hard to add; it is that **okf's evolution is this program's
14
+ evolution**, and the failures arrive without a stack trace.
15
+
16
+ This file records the shape of that drift, found by catching up four okf minor
17
+ versions at once (1.9 → 1.13) and discovering that the TUI had been wrong in
18
+ production for a release without anything going red.
19
+
20
+ # Every drift found so far was silent
21
+
22
+ Four, and not one of them raised:
23
+
24
+ | What okf did | How it presented here |
25
+ |---|---|
26
+ | renamed the catalog's `area` to `top_dir` (1.12.0) | `rows.map { row[:area] }` returned `[nil]`, so **every** bundle reported `1 areas` — a wrong number that looks like a right one |
27
+ | added `Search.prepare`/`with` (1.11.0) | nothing broke; the TUI simply kept rebuilding a full-text index per query, 392 ms where 12 ms was available |
28
+ | added registry groups (1.12.0) | `registry.listing` returns bundles only, so groups were absent from the one view whose job is the registry |
29
+ | added local-registry discovery (1.12.0) | `Registry.load` without `cwd:` still worked — it just answered about a *different registry* than every other okf verb in the same directory |
30
+
31
+ Two patterns, and the second is the dangerous one:
32
+
33
+ - **A renamed field reads as nil.** Ruby hands back `nil` for a missing hash key,
34
+ so a derived-field rename cannot fail loudly. It becomes a plausible number.
35
+ - **A new capability is invisible by construction.** Nothing is broken when okf
36
+ adds a faster path or a new concept; the TUI is merely *less* than it was
37
+ written to be. No test can fail for a feature nobody has written yet, which
38
+ means the only defence is reading okf's changelog on purpose.
39
+
40
+ The renames are why the floor is now stated per capability in the gemspec, one
41
+ line each, naming the version and what fails without it. The additions are why
42
+ catching up is a periodic *task*, not an event that gets triggered.
43
+
44
+ # The defence that works: agreement, not assertion
45
+
46
+ The instinct after the `area` break is to assert the number: "this fixture has six
47
+ directories". That catches a regression in this code and nothing about drift — okf
48
+ could change what counts as a directory tomorrow and the test would keep passing
49
+ while the screen went wrong.
50
+
51
+ What works is asking okf the same question and comparing:
52
+
53
+ ```ruby
54
+ # dirs_test.rb — the dir facet, against okf's own subtree column
55
+ OKF::CLI.start([ "dirs", fixture(name), "--json" ], out: out, err: StringIO.new)
56
+ ```
57
+
58
+ `structure_test.rb` does the same against `okf graph --traffic`, parsing its
59
+ **human** table and matching the rendered frame row for row. Both run in-process,
60
+ so they cost milliseconds, and both fail the moment okf and this disagree about a
61
+ number — which is the actual contract, and the one an asserted constant does not
62
+ express.
63
+
64
+ Where a formula had to be reproduced (cohesion is `internal / (internal + out +
65
+ in)`, okf's arithmetic over `Bundle::Skeleton`), the agreement test is what makes
66
+ the copy safe. A comment saying "same as okf's" is a claim; the test is the check.
67
+
68
+ # Prefer inheriting a rule to copying it
69
+
70
+ Two couplings arrived in this pass, and they were resolved differently on purpose:
71
+
72
+ **The ref grammar is inherited.** `OKF::TUI::Refs` subclasses `OKF::CLI::Command`
73
+ to reach `resolve_ref_expanding`, okf's own resolver — so `@slug`, bare `@`,
74
+ `@group` fan-out, the vanished-member note and the `@all` refusal all behave
75
+ identically to `okf server`, messages and exit codes included. That is a coupling
76
+ to a **private** method, accepted because the alternative is a second copy of a
77
+ grammar with five branches and its own error strings, which is precisely the
78
+ "second argument grammar" [one-door-the-plugin-seam](/decisions/one-door-the-plugin-seam.md)
79
+ exists to prevent. `refs_test.rb` pins the seam by name, so okf moving it fails
80
+ loudly rather than quietly restoring `not a directory`.
81
+
82
+ **The `--dir` matching rule is reimplemented**, in one line
83
+ (`Model.under_dir?`), and that is not inconsistent. okf's flag handling is mostly
84
+ about *arguments a human typed* — the `root` alias, a trailing slash, case folding
85
+ — and none of it applies to a facet whose value the TUI got out of
86
+ `Bundle#directories` and whose rows carry `OKF.dir_of`. Both sides are already
87
+ okf's canonical spellings, so what is left is the set relation between two values
88
+ okf handed over. The agreement test is what holds it to okf's answer.
89
+
90
+ The rule of thumb: **inherit a grammar, reimplement a predicate, and test
91
+ agreement either way.**
92
+
93
+ # What this costs, and why it is still right
94
+
95
+ The bill is real: a periodic read of okf's changelog, a floor that has to be
96
+ raised deliberately, and a test suite that runs okf's CLI in-process to check its
97
+ own arithmetic. None of that would exist if the TUI parsed markdown itself.
98
+
99
+ It is still right, and the four drifts above are the argument. Every one of them
100
+ was a *number this program did not have to compute*, and three of the four were
101
+ okf getting better at answering it. A TUI with its own catalog would not have had
102
+ the `area` break — it would have had its own, permanently, with no upstream to
103
+ inherit the fix from.
104
+
105
+ # Citations
106
+
107
+ [1] `okf-tui.gemspec` — the seven capabilities and the version each shipped in.
108
+ [2] `test/integration/dirs_test.rb` — `okf_subtree_counts`, the `okf dirs --json`
109
+ oracle; and the `nested` fixture, which exists because at one directory level
110
+ `dir` and `top_dir` are the same string and an assertion cannot tell them apart.
111
+ [3] `test/integration/structure_test.rb` — `okf_traffic_rows`, parsing okf's human
112
+ traffic table; the row count is asserted first so the comparison loop cannot
113
+ pass vacuously.
114
+ [4] `lib/okf/tui/refs.rb` — the inherited grammar, and why it subclasses Command
115
+ without ever being registered.
116
+ [5] okf `CHANGELOG.md`, 1.12.0 "Changed": "The derived `area` field is renamed
117
+ `top_dir`" — "`area` was never the OKF spec's word (`grep -ci area SPEC.md` → 0)".
118
+ [6] Measured 2026-08-13 over five registered bundles / 129 concepts: first query
119
+ 391.8 ms, then 16.2 / 13.0 / 12.0 / 14.4 ms. Before, every query paid the first
120
+ figure.
@@ -0,0 +1,131 @@
1
+ ---
2
+ type: Decision
3
+ title: One door — the plugin seam is the entry point
4
+ description: okf-tui ships no executable; `okf tui` is the only way in, and the registration stays cheap enough that a run wanting `okf lint` pays nothing for it.
5
+ tags: [okf-coupling, dependencies, cli]
6
+ timestamp: 2026-07-19T18:00:00Z
7
+ ---
8
+
9
+ # Overview
10
+
11
+ okf's CLI dispatches through a registry and finds extensions by convention: any
12
+ gem with `okf/plugin.rb` on its load path can add a verb. This gem ships one, and
13
+ that file is its **only** entry point: there is no `exe/`, and `okf help` lists
14
+ `tui` under `installed extensions:`. It is the second registry this gem plugs
15
+ into, after
16
+ [the search facade](search-facade-coupling.md) — and unlike that one it carries
17
+ no unreleased-API risk, because a plugin file an old okf never discovers is
18
+ simply inert.
19
+
20
+ Installing the gem is the whole installation. There is no config file, no
21
+ `okf plugin add`, and nothing to edit in okf — which is the point of a convention
22
+ over a list. See okf's
23
+ [extension points](https://github.com/serradura/okf-gem/blob/main/.okf/design/extension-points.md).
24
+
25
+ # One front end, because two would drift
26
+
27
+ There was an `exe/okf-tui`. It did nothing but call the same `CLI.run` the
28
+ plugin's `#call` calls, and it went before the first release, while removing a
29
+ name still cost nobody anything. okf-mcp had already made the same call for the
30
+ same reason, and its gemspec records it in one line: a second binary that only
31
+ aliases a verb is one more name to install, document and keep working.
32
+
33
+ The cost of keeping it was not the file. It was that **two front ends are two
34
+ argument grammars, and the drift between them is invisible** — each one passes
35
+ its own tests while they disagree, because nothing compares them. That is not
36
+ hypothetical here: `--home` had to be dropped precisely because the same tool
37
+ answered differently depending on which door you came through (see below). The
38
+ door that survives is the one a user is told about in `okf help`.
39
+
40
+ What the seam buys instead is discoverability. Somebody who installed okf-tui
41
+ finds it in okf's own map without having to learn that a second command exists.
42
+
43
+ The obligation that replaces the two-door test: **the adapter must carry argv
44
+ and the streams and add nothing else.** `plugin_test.rb` drives the same run
45
+ through `OKF::CLI.start` and straight into `OKF::TUI::CLI.run`, and compares the
46
+ exit code and the message — driven in the same
47
+ [headless, stream-injected](../testing/headless-frames.md) style every other
48
+ surface here is proven in. `terminal_test.rb` covers what neither of those can,
49
+ being in-process: it spawns okf's executable on a real pty, so process boot and
50
+ plugin *discovery* are on the path too.
51
+
52
+ # Registration must not build anything
53
+
54
+ okf reads `okf/plugin.rb` whenever a verb misses or `okf help` runs. That file
55
+ therefore cannot be expensive, and this gem's library is: six TTY gems plus
56
+ kramdown and rouge. Somebody typing `okf lint` should not pay for a terminal
57
+ toolkit they are not going to see.
58
+
59
+ So the plugin file registers a class and nothing else; `require "okf/tui/cli"`
60
+ happens inside `#call`. A subprocess test asserts that loading the plugin leaves
61
+ `TTY::Box` undefined — in-process it would assert nothing, since the suite has
62
+ long since loaded it.
63
+
64
+ This is the same shape as okf's own laziness one level up: discovery itself only
65
+ runs for an unknown verb or for `help`, so a built-in verb never even reads this
66
+ file.
67
+
68
+ # `input:` is why the base command carries a terminal
69
+
70
+ Every verb okf ships is a one-shot read that never looks at stdin. This one
71
+ cannot work without it — a full-screen UI with no terminal has nothing to drive —
72
+ so `Command` carries an `input:` alongside `out:`/`err:`, and the TUI takes it
73
+ from there rather than reaching for `$stdin`.
74
+
75
+ That is not ceremony. Reaching past the injected streams would put the command
76
+ outside the stream discipline
77
+ [the whole suite depends on](../testing/headless-frames.md), and it would make
78
+ "it refuses to run without a terminal" unassertable through `okf tui` — the
79
+ refusal only works because the stream okf handed down is the one the TUI checks.
80
+
81
+ # It could not ship before okf did, and that debt is paid
82
+
83
+ The seam was once newer than any okf release, so `okf tui` did not work against
84
+ the published gem and this suite would have been asserting against a CLI with no
85
+ `register` at all — the same shape as
86
+ [the search facade](search-facade-coupling.md). It was handled the way that one
87
+ taught rather than discovered again: the plugin tests **skipped, named**, on an
88
+ okf without the registry, and the floor stayed honest rather than guessing at a
89
+ version that did not exist yet — guessing one is how the last dependency fix
90
+ [became the next bug](../testing/ci-matrix.md).
91
+
92
+ Both debts are settled. `OKF::CLI.register` shipped in okf 1.10.0, the floor now
93
+ names `okf >= 2.0, < 3`, and the skip is gone — deleted on its own instruction,
94
+ because a skip left in place after the reason for it is gone is a suite that
95
+ quietly tests nothing.
96
+
97
+ One piece of that arrangement stays, and it should: `lib/okf/plugin.rb` raises a
98
+ named `LoadError` rather than a bare `NameError` if it is ever required against
99
+ an okf without the registry. The floor makes that unreachable through
100
+ resolution, and unreachable is not the same as impossible — a second okf ahead
101
+ of the intended one on the load path is exactly how it would happen.
102
+
103
+ # What it cost: the `--home` flag
104
+
105
+ `okf-tui --home DIR` is gone. okf had dropped its own `--home` before 1.8 for a
106
+ reason it wrote down — a flag whose whole job is to name a location `$OKF_HOME`
107
+ already names has to be remembered on the verbs that offer it and forgotten on
108
+ the ones that do not — and this gem kept it anyway.
109
+
110
+ That was survivable while `okf-tui` was its own binary. It stopped being
111
+ survivable the moment `okf tui` existed, because then the same tool had two
112
+ grammars depending on which door you came through. The flag went first and the
113
+ binary followed, which is the order the lesson actually arrived in: the drift was
114
+ visible before its cause was.
115
+
116
+ The library keyword is untouched: `App` and `Workspace` still take `home:`, which
117
+ is how an embedding app and the suite name a registry without mutating a
118
+ process-global. That is exactly the split okf itself draws — the env var is the
119
+ CLI's only lever, the keyword is the library's.
120
+
121
+ # Citations
122
+
123
+ [1] `lib/okf/plugin.rb` — the whole seam, including why the heavy require is in `#call`.
124
+ [2] `okf-tui.gemspec` — the comment recording why there is no `spec.executables`.
125
+ [3] `test/integration/plugin_test.rb` — the dispatcher adding nothing but the
126
+ streams, and the subprocess check that registering does not load tty-box.
127
+ [4] `test/integration/terminal_test.rb` — okf's own executable on a real pty, the
128
+ one test that walks process boot and plugin discovery rather than the
129
+ in-process dispatcher.
130
+ [5] Verified 2026-07-19: `okf --help` lists `tui` under `installed extensions:`;
131
+ `okf validate` still runs without triggering discovery at all.
@@ -0,0 +1,175 @@
1
+ ---
2
+ type: Decision
3
+ title: It Writes the Registry, Never a Bundle
4
+ description: The line the TUI's side effects sit on is not read-versus-write — it is the registry versus the knowledge; what that admits, what it excludes, and why `registry init` is on the far side of it.
5
+ tags: [registry, boundary, side-effects]
6
+ timestamp: 2026-08-13
7
+ ---
8
+
9
+ # Overview
10
+
11
+ The TUI has side effects, and more of them than a reader expects from something
12
+ described as a viewer. It registers bundles, removes them, renames slugs, moves
13
+ the default. The question that keeps coming up — *should it also create groups?
14
+ rename them? do everything the CLI does?* — cannot be answered by asking whether
15
+ a feature writes to disk, because the answer is already yes.
16
+
17
+ The line is **the registry, not read-versus-write**: this program edits the user's
18
+ *configuration* freely and never touches the *knowledge*.
19
+
20
+ # What that admits
21
+
22
+ Seven of okf's eight `registry` verbs have a place here, and it is the same place —
23
+ the bundles view, where `Workspace` owns every write and each is followed by a
24
+ reload, per `AGENTS.md` constraint 8:
25
+
26
+ | Key | `Workspace` | okf CLI |
27
+ |-----|-------------|---------|
28
+ | `a` | `add` | `okf registry set <dir>` |
29
+ | `x` | `remove` | `okf registry del <@slug>` — spans a group |
30
+ | `d` | `make_default` | `okf registry default <@slug>` |
31
+ | `n` | `rename` | `okf registry rename <@slug> <new>` — spans a group |
32
+ | `c` | `create_group` | `okf registry group <slug> <@member…>` |
33
+ | `+` | `add_to_group` | `okf registry group <slug> <@member…>` |
34
+ | `-` | `remove_from_group` | `okf registry ungroup <slug> <@member…>` |
35
+ | — | the view itself | `okf registry list` |
36
+
37
+ Group create/edit was the question that forced this file to be written, and it
38
+ falls on the admitted side: a group is registry configuration in exactly the sense
39
+ a slug rename is. Stopping at read-only would have put a `@mkt` row on a screen
40
+ where `x` already deletes a bundle, and refused to let you add a member to it —
41
+ not restraint, just an arbitrary stopping point.
42
+
43
+ **The members come from the scope, not from a typed list.** `◉` already means
44
+ "these bundles" in this view, so the gesture is toggle what you want and then name
45
+ it (`c`), or put the cursor on a group and push the scope into it (`+`) or out of
46
+ it (`-`). That is a gesture the CLI cannot offer, and it is why these keys are
47
+ worth having rather than being a second spelling of `okf registry group`.
48
+
49
+ okf owns every cascade. `set_group` creates-or-adds and refuses a cycle,
50
+ `unset_group_members` deletes a group it empties, and `rename`/`del` span a group
51
+ slug and cascade through every member list that names it. None of that is
52
+ reimplemented; `groups_test.rb` asserts the *effects* — that renaming `@docs`
53
+ leaves `@everything` naming `@papers` — precisely so the cascades stay okf's.
54
+
55
+ # What it excludes
56
+
57
+ Nothing in `lib/` writes a bundle. There is no `OKF::Bundle::Writer` reference in
58
+ this gem, and that is the invariant worth grepping for: no concept editing, no
59
+ "fix this lint warning for me", no touching a `log.md`. Authoring is okf's CLI and
60
+ its companion skill, which have the vocabulary and the review path for it.
61
+
62
+ The reason is not timidity. A registry is small, local, reversible and *about the
63
+ session* — a list of references, which is why removing a bundle famously does not
64
+ delete it. A bundle is the knowledge itself, and a full-screen keyboard UI is a
65
+ poor place to make an irreversible edit to it by muscle memory.
66
+
67
+ # `registry init` is excluded, for a different reason
68
+
69
+ okf 1.12.0 added `okf registry init`, which creates a project-local
70
+ `.okf-registry.json`. It is registry configuration, so the line above admits it —
71
+ and it is still not offered, on mechanics rather than principle.
72
+
73
+ The registry is resolved **once, at boot**: `Workspace#load_entries` opens it, and
74
+ every reload after that goes through `Registry#reopen` on the same file. Creating
75
+ a *new* registry mid-session is not an edit to the thing the session is on, it is
76
+ a **re-anchoring** — after it, the file every other okf verb in that directory
77
+ would now resolve is one this session has never read. Either the key appears to do
78
+ nothing, or the entire workspace silently swaps out from under every open view,
79
+ including the active bundle the other five views are about.
80
+
81
+ Both outcomes are worse than not having the key. The CLI is the right door, and
82
+ once the file exists the TUI discovers it on the next launch — which is
83
+ `Workspace.new(cwd:)`, and the fix recorded in
84
+ [which-registry](/interaction/which-registry.md).
85
+
86
+ # The removal key, got wrong twice
87
+
88
+ The first cut put removal on `-` at the group row, acting on the bundles the scope
89
+ and the member list had in common. It shipped, and the report came back at once:
90
+ someone looking for how to *turn the scope off* pressed `-`, and it deleted a group.
91
+
92
+ Three things lined up, and the missing confirmation was only one of them:
93
+
94
+ - **`-` reads as "remove", and the nearest removable thing was the scope.** Where
95
+ `◉` means "a search covers this", a key marked `-` is a scope key until proven
96
+ otherwise.
97
+ - **The footer had dropped the scope keys on a group row** and labelled the member
98
+ keys `+/- add/remove scoped` — so the one screen someone stands on while wanting
99
+ to stop searching everything offered no `A`/`N`, and did offer a `-` described
100
+ with the word *scoped*. Close to an instruction.
101
+ - **okf deletes a group whose last member leaves.** Right for a CLI where the
102
+ command names its members explicitly; here it meant one keystroke could destroy
103
+ the group rather than trim it.
104
+
105
+ Adding a confirmation fixed the damage and left the real fault in place, which the
106
+ next round of feedback named: `-` acted on **`scope ∩ members`, a set with no row
107
+ on screen**. To predict the key you had to intersect two lists in your head, and
108
+ the review comment was simply "it took me some time to understand how this works".
109
+
110
+ The fix that stuck was structural, and it came from the maintainer rather than from
111
+ me: **give the groups a pane of their own.** The bundles view is three panes now —
112
+ bundles, groups, and the detail of whichever has focus — with `Tab` cycling.
113
+
114
+ That does more than tidy the layout. Two panes hold *two selections at once*, and
115
+ suddenly every editing key has visible rows to name: `+` is the bundle under the
116
+ bundles cursor joining the group selected below, `-` is the member under the members
117
+ cursor. Nothing reads the search scope any more except `c`, where naming the bundles
118
+ you have been searching together is precisely the gesture. The key I had got wrong
119
+ twice stopped being hard the moment the layout gave it something to point at.
120
+
121
+ It also fixed a fault nobody had reported yet: the groups were a heading inside the
122
+ bundle list, and a heading scrolls away. Thirteen registered bundles put them below
123
+ the fold on a short terminal — no way to show a thing you are meant to select. A
124
+ pane is always on screen, and `Tab` is the jump to it.
125
+
126
+ Three rules came out of it, in increasing order of how far they carry:
127
+
128
+ 1. A write that can lose configuration confirms, and the question names the
129
+ outcome — `remove @nested — its last, so @docs goes too`, not a bare "remove?".
130
+ 2. **Act on a row, not on a set the reader has to compute.** A key whose effect is
131
+ an intersection of two lists cannot be predicted from the screen, and every
132
+ misreading of it is reasonable.
133
+ 3. **A hint must not borrow the vocabulary of a different mechanism.** Two
134
+ independent axes live in this view — the active bundle and the scope
135
+ ([cross-bundle-scope](/interaction/cross-bundle-scope.md)) — so a registry write
136
+ described in the scope's words will be read as the scope's key.
137
+
138
+ Reuse also mattered: `Tab` already meant "switch pane" in browse and graph, so three
139
+ panes cost a reader nothing new, and `Esc` peels them one at a time before the
140
+ filter, exactly as
141
+ [esc-peels-one-layer](/interaction/esc-peels-one-layer.md) requires. A key that
142
+ belongs to a different pane *says where it lives* rather than going silent — a key
143
+ that quietly stopped working is indistinguishable from a broken one.
144
+
145
+ The last thing, and the smallest, was the one the report actually opened with:
146
+ "it is not clear how to control the scope." It was not. Neither `A` nor `N` appeared
147
+ in the footer on a bundle row, and the footer truncates, so at 80 columns everything
148
+ past `d` was already off screen. The scope is one of this view's two axes
149
+ ([cross-bundle-scope](/interaction/cross-bundle-scope.md)); it belongs in the
150
+ visible prefix, ahead of the config keys, and that is where it is now.
151
+
152
+ # The test that keeps it honest
153
+
154
+ Every write test builds its own registry: `with_registry` under a temporary
155
+ `$OKF_HOME`, and `with_local_registry` for the project-local file. Nothing in the
156
+ suite may touch the real `~/.okf` — it is the maintainer's own configuration, and
157
+ this is a suite that exercises `remove`.
158
+
159
+ Two states in `groups_test.rb` are reached by writing the registry JSON directly,
160
+ because okf's own API refuses to produce them: a member naming nothing registered,
161
+ and a cycle (`group cycle: @g would contain itself`). Both are legitimate things
162
+ to survive, since okf invites you to *commit* a local registry — so a hand-edited
163
+ file is a normal input, not an abuse.
164
+
165
+ # Citations
166
+
167
+ [1] `lib/okf/tui/workspace.rb` — the seven writes, each returning a status message
168
+ and reloading; `not_registry_backed` for the ad-hoc case, which has no registry
169
+ to change.
170
+ [2] `AGENTS.md` constraint 8 — the same boundary, stated where a change is judged.
171
+ [3] `grep -rn "Bundle::Writer" lib/` → no matches, 2026-08-13.
172
+ [4] okf `CHANGELOG.md` 1.12.0 — `okf registry init`, and the discovery rule that
173
+ makes the nearest `.okf-registry.json` win.
174
+ [5] `test/test_helper.rb` — `with_registry`, `with_local_registry`, and the
175
+ `$OKF_HOME` save/restore around both.
@@ -0,0 +1,59 @@
1
+ ---
2
+ type: Decision
3
+ title: Ruby 2.4 Floor, Inherited
4
+ description: okf-tui takes okf's floor rather than its own, which costs the ergonomic syntax and forces the development tooling to load conditionally.
5
+ tags: [ruby-floor, dependencies]
6
+ timestamp: 2026-07-18
7
+ ---
8
+
9
+ # Overview
10
+
11
+ `required_ruby_version = ">= 2.4.0"`, taken from [okf](@okf), which takes it from
12
+ rack. A UI over a library that runs on the Ruby an OS already ships would be a
13
+ strange thing to require a newer Ruby for.
14
+
15
+ The cost lands in two places that are not obvious until you hit them.
16
+
17
+ # The port had to give up keyword_init
18
+
19
+ The prototype built its one-off structs the modern way:
20
+
21
+ ```ruby
22
+ Prompt = Struct.new(:kind, :label, :buffer, :subject, :free_text, keyword_init: true)
23
+ ```
24
+
25
+ `keyword_init:` is Ruby **2.5**. RuboCop at `TargetRubyVersion: 2.4` parses this
26
+ happily — it catches *syntax*, not *APIs* — so nothing local complained; it would
27
+ have failed on the floor at runtime. It is now a plain class with an
28
+ `attr_reader` and a `free_text?` predicate.
29
+
30
+ This is the general trap: the floor is a library-API constraint that only two
31
+ things actually prove — reading the version each method arrived in, and running
32
+ the suite on 2.4.
33
+
34
+ # The tooling cannot install on the floor it guards
35
+
36
+ RuboCop needs Ruby ≥ 2.5, so requiring it unconditionally makes `bundle install`
37
+ fail on 2.4 — the tooling that enforces the floor blocks the floor. Both dev gems
38
+ load conditionally, and the Rakefile degrades to test-only when RuboCop is
39
+ absent:
40
+
41
+ ```ruby
42
+ if RUBY_VERSION >= "2.7"
43
+ gem "irb"
44
+ gem "rubocop", "~> 1.21"
45
+ end
46
+ ```
47
+
48
+ So on 2.4 the *suite* is the proof and lint is skipped; on 2.7+ the default task
49
+ runs both. Same split okf uses, and it is why
50
+ [the CI matrix](/testing/ci-matrix.md) — not RuboCop — is what actually holds
51
+ the floor.
52
+
53
+ Nothing is pinned *down* to make this work — see
54
+ [no-version-ceilings](/decisions/no-version-ceilings.md).
55
+
56
+ # Citations
57
+
58
+ [1] `okf-tui.gemspec`, `Gemfile`, `Rakefile`.
59
+ [2] Verified in `ruby:2.4` (Docker) and on CI, which runs 2.4 through 4.0.