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,146 @@
1
+ ---
2
+ type: Decision
3
+ title: The Search Facade Coupling
4
+ description: The search view rides okf's engine facade — `across` for the routing, and since okf 1.11.0 a corpus prepared once and queried many times; the `fuzzy` flag is what selects the engine, and it is load-bearing in a way it does not look.
5
+ tags: [okf-coupling, search, dependencies]
6
+ timestamp: 2026-08-13
7
+ ---
8
+
9
+ # Overview
10
+
11
+ The search view calls `OKF::Bundle::Search.across`, okf's engine facade, which
12
+ merges several bundles into **one** ranked corpus.
13
+
14
+ **This has shipped.** `across` was unreleased when the view was built — `okf`
15
+ 1.8.0 on RubyGems had no such method — but okf **1.9.0 carries it**, verified
16
+ from a clean install with no checkout and no bundler in sight.[1] The coupling
17
+ that shaped this file is resolved.
18
+
19
+ The coupling is deliberate, because per-bundle indexes would be a different and
20
+ worse product: BM25 weighs a term by how rare it is *in the corpus*, so indexing
21
+ each bundle separately scores the same match differently depending on which
22
+ bundle it came from. One index makes one corpus, and cross-bundle scores that
23
+ can be compared are the whole point of the view. See
24
+ [cross-bundle-scope](/interaction/cross-bundle-scope.md).
25
+
26
+ # `fuzzy: true` is what selects the engine
27
+
28
+ okf has since made the facade route between engines, and the **BM25 index is no
29
+ longer the default** — a plain search runs the regexp scan, because a one-shot
30
+ CLI cannot amortize an index build. okf-tui still gets the index, but only as a
31
+ consequence of asking for a capability the scan does not have:
32
+
33
+ ```ruby
34
+ OKF::Bundle::Search.across(pairs, terms, fuzzy: true)
35
+ ```
36
+
37
+ The registry picks the default engine first and falls through to one that can
38
+ answer, so `fuzzy` is what routes this to minifts.[2] That makes the flag
39
+ load-bearing in a way it does not look: **dropping `fuzzy: true` would silently
40
+ change the engine**, and with it the ranking — no error, no missing method, just
41
+ different results and no BM25 scores. The screen would still work.
42
+
43
+ Unlike the CLI, the TUI is long-lived and searches repeatedly, so paying for the
44
+ index is the right trade here even though it is the wrong default there.
45
+
46
+ # Long-lived means the corpus is held, not rebuilt
47
+
48
+ Which was the point, and for a release the code did not act on it. `Search.across`
49
+ rebuilds **everything** per call — the documents and the index over them — and the
50
+ TUI called it on every submitted query. okf 1.11.0 had already added the pair for
51
+ this exact case, and uses them in its own server:
52
+
53
+ ```ruby
54
+ corpus = OKF::Bundle::Search.prepare(pairs) # once, per scope
55
+ OKF::Bundle::Search.with(corpus, terms, fuzzy: true) # per query
56
+ ```
57
+
58
+ Measured over five registered bundles, 129 concepts: **392 ms** for the first
59
+ query, then **12–16 ms**. Before, every query paid the 392 ms.[3] It is the same
60
+ arithmetic okf used to justify the opposite default — an index build amortized over
61
+ one query is a bad trade, and over many it is the whole point.
62
+
63
+ Two details that are not obvious from the API:
64
+
65
+ - **No `engine:` is passed to `prepare`.** That argument only moves the index build
66
+ *earlier*; there is no boot here to move it into, and a session that never
67
+ searches should not pay to index bundles nobody opened. The index is built lazily
68
+ on the first query and memoized inside the corpus, which is okf's behaviour, not
69
+ something arranged here.
70
+ - **A held corpus is a snapshot, and staleness is the failure mode.** It is keyed
71
+ on the scoped slugs and dropped outright by `load_entries`, so a scope change or
72
+ a reload cannot be answered from an index built over a different set. okf takes
73
+ the same care in its hub and gives the reason: a held index outliving the set it
74
+ was built from is a *wrong* answer rather than a slow one.
75
+
76
+ `search_test.rb` asserts the mechanism — built once, reused across queries, dropped
77
+ on scope change and on reload — and separately that the held corpus returns the
78
+ identical ranking to `across`, since this is meant to be a performance change and
79
+ nothing else.
80
+
81
+ # The floor records it
82
+
83
+ `okf >= 1.9` was the first honest floor — the version `across` shipped in, and
84
+ what made the gem publishable at all: the `okf >= 0.1` placeholder it replaced
85
+ was provably false, since no okf of that line could answer the search view.
86
+
87
+ It has moved with the kernel since, one line per capability, because each
88
+ absence fails silently rather than loudly — see
89
+ [okf-capability-drift](/decisions/okf-capability-drift.md), which records what
90
+ each one is; `prepare`/`with` above is among them. It stands at `okf >= 2.0`
91
+ today, with the `< 3` ceiling that is
92
+ [no-version-ceilings](/decisions/no-version-ceilings.md)' one earned exception.
93
+
94
+ The proof of a floor is the resolution itself, never the local suite: resolve
95
+ the *published* okf — drop the `path:` source, drop the lockfile — and run the
96
+ suite on the floor and on a modern Ruby.
97
+
98
+ # Why it still needs a boot check
99
+
100
+ The check has not become redundant — its meaning has changed. It used to guard
101
+ against an okf that had not shipped the method yet; it now guards against an
102
+ installed okf **older than the floor**, which is a thing users will actually have.
103
+
104
+ The failure mode is what makes this worth recording. `Workspace#search` rescues
105
+ a failed search into an empty result — correct for a query okf cannot parse,
106
+ badly wrong for a method that is not there, because then **every** search
107
+ answers "no matches" and the screen reads as an empty bundle rather than a
108
+ broken install.
109
+
110
+ It now checks all three methods search actually calls — `across`, `prepare`,
111
+ `with` — because a `prepare` that is not there fails the same silent way, rescued
112
+ into "no matches".
113
+
114
+ That is exactly how it presented: running the CLI via `ruby -Ilib` outside
115
+ bundler let RubyGems activate the installed `okf` 1.8.0, and search silently
116
+ found nothing.[4] The prototype could never hit it — it put the okf checkout on
117
+ `$LOAD_PATH` directly, so it always had the unreleased method.
118
+
119
+ So the check moved out of the rescue and up to boot:
120
+
121
+ ```ruby
122
+ OKF::TUI.search_capable? # across, prepare and with — all three
123
+ ```
124
+
125
+ The CLI refuses to start and exits `1`, naming **the okf file that answered** —
126
+ not the version, the file — because the usual cause is a second okf ahead of the
127
+ intended one on the load path, and a version number does not tell you that.
128
+
129
+ # Citations
130
+
131
+ [1] Verified 2026-07-19 in a clean `ruby:3.2-slim` container — no checkout, no
132
+ bundler: `gem install okf` resolved 1.9.0, `Bundle::Search.respond_to?(:across)`
133
+ → `true`, `engine_for([:fuzzy])` → `OKF::Bundle::Search::Index`. RubyGems lists
134
+ okf 1.9.0 as the current release.
135
+ [2] Verified 2026-07-19 against the okf checkout: `engine_for([:fuzzy])` → `index`,
136
+ `engine_for([])` → `scan`; `DEFAULT_ENGINE = :scan` in
137
+ `lib/okf/bundle/search.rb`.
138
+ [3] Measured 2026-08-13 against the registry's own five bundles (129 concepts):
139
+ `across` per query 391.8 ms; with a held corpus 16.2 / 13.0 / 12.0 / 14.4 ms
140
+ after the first. On the six test fixtures (36 concepts): 62–80 ms against
141
+ 2.7–3.0 ms.
142
+ [4] Reproduced 2026-07-18: `ruby -Ilib` outside bundler loaded okf 1.8.0 from the
143
+ mise gem path, `Bundle::Search.respond_to?(:across)` → `false`, search
144
+ returned 0 hits for a term the checkout finds. Under `bundle exec` the same
145
+ query returned 1 hit ("orphan") and 11 ("registry").
146
+ [5] `lib/okf/tui.rb` — `OKF::TUI.search_capable?`.
@@ -0,0 +1,73 @@
1
+ ---
2
+ type: Decision
3
+ title: The Undeclared Width Dependency
4
+ description: Ui.width rests on unicode-display_width, which arrives through tty-box rather than the gemspec — accepted deliberately, and guarded by a test that fails loudly if it stops arriving.
5
+ tags: [dependencies, rendering, terminal]
6
+ timestamp: 2026-07-19
7
+ ---
8
+
9
+ # Overview
10
+
11
+ [Column measurement](/rendering/ansi-aware-width.md) is the invariant the whole
12
+ layout rests on, and it is computed by a gem the gemspec does not name:
13
+
14
+ ```ruby
15
+ begin
16
+ require "unicode/display_width"
17
+ rescue LoadError
18
+ end
19
+ ...
20
+ if defined?(Unicode::DisplayWidth)
21
+ Unicode::DisplayWidth.of(plain)
22
+ else
23
+ plain.length # the fallback
24
+ end
25
+ ```
26
+
27
+ It loads today because `tty-box` depends on it transitively. So the most
28
+ load-bearing measurement in the program is held up by another gem's dependency
29
+ graph.
30
+
31
+ # Why it stays that way
32
+
33
+ Declaring it was the obvious fix and was deliberately declined: the gem is
34
+ already installed for every user, nothing changes for anyone today, and the
35
+ guarded `require` means the program degrades rather than crashes.
36
+
37
+ The accepted risk is narrow and worth naming precisely. If `tty-box` ever drops
38
+ `unicode-display_width`:
39
+
40
+ - nothing crashes — the `rescue LoadError` swallows it;
41
+ - ASCII keeps rendering correctly — the fallback counts characters, which is the
42
+ right answer for ASCII;
43
+ - **CJK, emoji and combining marks start shearing the frame**, and only there.
44
+
45
+ So the failure is silent, partial, and shows up in a user's terminal rather than
46
+ in CI. That combination is why it needed a guard even though it needed no
47
+ gemspec line.
48
+
49
+ # What guards it
50
+
51
+ Two checks, and the split between them is the point:
52
+
53
+ | Check | Catches |
54
+ |-------|---------|
55
+ | the `wide` fixture rendered at four sizes | the shear itself, end to end |
56
+ | `Ui.width("日本語") == 6` plus `assert defined?(Unicode::DisplayWidth)` | the gem going missing |
57
+
58
+ The second is deliberately **not** a rendering assertion. Were the gem to vanish,
59
+ the layout and any test measuring through `Ui.width` would both fall back to
60
+ counting characters and would agree with each other — passing while the real
61
+ terminal sheared. A test that degrades alongside the code it checks is not a
62
+ guard.
63
+
64
+ Both were verified by forcing the fallback: a row measured 87 columns in an
65
+ 80-column terminal, and CJK measured 3 instead of 6.[1]
66
+
67
+ # Citations
68
+
69
+ [1] Sabotage run 2026-07-19: `Ui.width` reduced to `plain.length`, both checks
70
+ failed as predicted, then restored — 38 runs green.
71
+ [2] `lib/okf/tui/ui.rb` — the guarded require and the fallback.
72
+ [3] `test/integration/geometry_test.rb` — `WIDE_STATES` and the
73
+ display-columns check.
data/.okf/index.md ADDED
@@ -0,0 +1,28 @@
1
+ ---
2
+ okf_version: "0.1"
3
+ ---
4
+
5
+ # okf-tui knowledge bundle
6
+
7
+ The non-obvious knowledge behind **okf-tui** — the full-screen terminal UI over
8
+ [okf](@okf) bundles. The `README` documents what the six views answer and which
9
+ keys drive them, and `AGENTS.md` carries the contracts a change has to keep; this
10
+ bundle deliberately restates neither.
11
+
12
+ What it captures is what the code cannot tell you on its own: *why* the
13
+ interaction model is what it is (each piece of it arrived at by getting it wrong
14
+ first), the terminal-composition arithmetic that breaks the moment colour is
15
+ involved, the coupling to an okf API and the scaffolding that outlived it, and
16
+ what each layer of the test suite can and cannot catch.
17
+
18
+ The through-line, if there is one: **this program invents no analysis.** okf owns
19
+ every judgement on screen, so the knowledge worth recording here is not what the
20
+ bundles say — it is how a screen shows them without lying, and where doing that
21
+ turned out to be harder than it looked.
22
+
23
+ # Areas
24
+
25
+ * [Decisions](decisions/) - The choices and their tradeoffs: the analysis boundary with okf, the search facade and the branch it outlived, the inherited Ruby floor, and why no dependency carries a ceiling.
26
+ * [Interaction](interaction/) - The keyboard model — key routing and its modes, Esc as a stack, submitted rather than live search, following a link out of the page, and the two independent axes of "which bundle".
27
+ * [Rendering](rendering/) - Composing a frame: ANSI-aware width, whole-frame painting, the tty-markdown trap that only appears in colour, and the one verdict a bundle wears everywhere.
28
+ * [Testing](testing/) - Frames proven without a terminal, the single pty walk that proves the binary boots, and the CI matrix that catches what a local run structurally cannot.
@@ -0,0 +1,61 @@
1
+ ---
2
+ type: Concept
3
+ title: Active Bundle and Scope Are Two Axes
4
+ description: What you are reading and what you are searching move independently, and the registry writes that reconcile them key on the directory rather than the slug.
5
+ tags: [ux, search, registry]
6
+ timestamp: 2026-07-19
7
+ ---
8
+
9
+ # Overview
10
+
11
+ "Which bundle" is two questions, and collapsing them into one would be the
12
+ obvious mistake:
13
+
14
+ - the **active bundle** (`●`) — what browse, graph and health are about. `Enter`
15
+ on a bundle changes it.
16
+ - the **scope** (`◉`) — which bundles a search covers. `space` toggles one, `A`
17
+ all, `N` none.
18
+
19
+ So you can read one bundle while searching all of them. Opening a hit that lives
20
+ in a *different* bundle switches the active bundle to it, which is the one place
21
+ the axes meet.
22
+
23
+ Scope is not a filter applied after the fact: the scoped bundles are indexed
24
+ **together**, as one corpus, so BM25 scores compare across them rather than only
25
+ within one. That is the same thing `okf search @all --fuzzy` does, and the reason
26
+ for [the facade coupling](/decisions/search-facade-coupling.md) — which is also
27
+ where the `fuzzy: true` that selects the BM25 engine at all is explained.
28
+
29
+ # Reconciliation keys on the directory
30
+
31
+ Every registry write reloads from disk, so the screen shows what the file now
32
+ says rather than what the in-memory list was talked into believing. That reload
33
+ has to carry the scope forward, and *how* it matches bundles is where three real
34
+ bugs lived:
35
+
36
+ | Bug | Cause | Fix |
37
+ |-----|-------|-----|
38
+ | a renamed bundle silently dropped out of scope | scope matched on slug, which a rename changes | reconcile on the **directory**, which survives a rename |
39
+ | setting a default moved the row under the cursor | the list reorders; the cursor held a position | the cursor follows the *bundle*, not the index |
40
+ | a newly added bundle was outside scope | the prior scope cannot mention a slug that did not exist | add it explicitly on add |
41
+
42
+ The through-line: **a slug is a label, the directory is the identity.** Anything
43
+ that has to survive a registry edit keys on the path.
44
+
45
+ # The registry is the user's config
46
+
47
+ Registry writes are real writes to the user's file. Removing a bundle never
48
+ touches the bundle on disk — the registry is a list of references — and a
49
+ workspace of directories named on the command line has no registry at all and
50
+ says so rather than pretending to configure one.
51
+
52
+ The suite never touches the real `~/.okf`: every test runs against a temporary
53
+ `$OKF_HOME`, and the pty test asserts the registry file is byte-identical
54
+ afterwards.
55
+
56
+ # Citations
57
+
58
+ [1] `lib/okf/tui/workspace.rb` — `reload`, `add`, `remove`, `make_default`,
59
+ `rename`.
60
+ [2] `test/test_helper.rb` — `with_registry`; `test/integration/terminal_test.rb`
61
+ — the unchanged-registry assertion.
@@ -0,0 +1,49 @@
1
+ ---
2
+ type: Decision
3
+ title: Search Submits, It Does Not Follow Typing
4
+ description: Enter runs the search rather than every keystroke, because a cross-bundle index is rebuilt per query — and the regression that hides is invisible on screen.
5
+ tags: [ux, search, keys]
6
+ timestamp: 2026-07-18
7
+ ---
8
+
9
+ # Overview
10
+
11
+ Typing in the search field changes nothing. `Enter` runs the search.
12
+
13
+ Live search was the first design, then a debounce, and both were wrong for the
14
+ same reason: a search here builds **one index across every scoped bundle** (see
15
+ [cross-bundle-scope](/interaction/cross-bundle-scope.md)), so a search per
16
+ keystroke rebuilds that index per keystroke. A debounce only makes the waste
17
+ intermittent — it is still doing the expensive thing on a query nobody asked for.
18
+
19
+ Submitting is also the better interaction: the results stay still while you type,
20
+ and the moment of asking is yours rather than a timer's.
21
+
22
+ # Why it needs a counting test
23
+
24
+ This is the regression worth guarding, and it is **invisible on a screenshot**.
25
+ Pointing the results back at `@query` instead of `@searched` still renders
26
+ correctly — the right hits appear, the screen is indistinguishable — it just
27
+ rebuilds a full cross-bundle index for every letter. Only a count catches it:
28
+
29
+ ```ruby
30
+ searches = 0
31
+ app.workspace.define_singleton_method(:search) { |_q| searches += 1; [] }
32
+ WORD.each_char { |char| app.handle(char); app.search_hits }
33
+ # typing runs 0; Enter runs exactly 1
34
+ ```
35
+
36
+ # The cache-key bug underneath
37
+
38
+ The memoized hits were keyed on the query string itself — which the field
39
+ *mutates in place* as you type. The key was the same object as the value's input,
40
+ so it always compared equal and the cache never invalidated. The filter had the
41
+ identical bug. Both keys are now `.dup`ed.
42
+
43
+ A cache keyed on a mutable string it does not own is not a cache; it is a
44
+ one-shot.
45
+
46
+ # Citations
47
+
48
+ [1] `lib/okf/tui/app.rb` — `@searched`, `@search_hits_key`.
49
+ [2] `test/integration/search_test.rb` — the counting test.
@@ -0,0 +1,70 @@
1
+ ---
2
+ type: Decision
3
+ title: Esc Peels One Layer
4
+ description: Esc ends the innermost active thing and nothing else — the rule that stops it from resetting a list cursor and losing the file the reader had open.
5
+ tags: [ux, keys, scar-tissue]
6
+ timestamp: 2026-07-18
7
+ ---
8
+
9
+ # The rule
10
+
11
+ `Esc` ends the **innermost** thing currently active, and only that. Press it
12
+ again for the next layer out. It is the mirror of
13
+ [the mode dispatch](/interaction/key-routing.md): the same layers, unwound one at
14
+ a time.
15
+
16
+ ```
17
+ link picker → find → filter / facet → (nothing; it never leaves the view)
18
+ ```
19
+
20
+ It never switches views. That was an explicit correction during design: pressing
21
+ Esc twice in the search view used to jump back to tab 1, which threw away the
22
+ results just as the reader was deciding what to do with them. Stopping the search
23
+ and moving on are two different intentions, and only one of them was being
24
+ offered.
25
+
26
+ # The bug that made the rule explicit
27
+
28
+ A find is a layer even after `Enter`. Submitting a find only releases the *field*
29
+ — the term stays lit and `n`/`N` still step through the matches — so the find is
30
+ still active while `@finding` is false.
31
+
32
+ Esc from that state fell through to the list's own Esc, which clears the filter
33
+ and calls `reset_cursor`. The reader was reading a concept and landed back on the
34
+ **first file in the bundle**, having lost the one they had open.[1]
35
+
36
+ ```ruby
37
+ if findable? && !@find.empty?
38
+ clear_find
39
+ return
40
+ end
41
+ ```
42
+
43
+ The file being read is the one thing a find must never cost.
44
+
45
+ # What stayed the same
46
+
47
+ The fix is narrow on purpose — a second Esc still clears the filter and resets
48
+ the cursor, and these were each verified unchanged:
49
+
50
+ | Esc pressed | Behaviour |
51
+ |---|---|
52
+ | after a submitted find | clears the find, cursor untouched |
53
+ | again, find gone | clears the filter, resets cursor |
54
+ | with no find at all | clears the filter, resets cursor |
55
+ | on a graph facet | clears the facet |
56
+ | on the help page find | clears the find, stays on help |
57
+ | in the link picker | closes it, body and find both untouched |
58
+
59
+ The picker row is the rule's newest test, and the one that would have repeated
60
+ the bug above: it is handled *inside* the picker's own key handler rather than in
61
+ `handle_escape`, so closing it can never fall through to the layers beneath —
62
+ which is exactly what the find failed to do. See
63
+ [following-links](/interaction/following-links.md).
64
+
65
+ # Citations
66
+
67
+ [1] Reproduced 2026-07-18 in `test/integration/browse_test.rb`: with `overview`
68
+ open the cursor moved 4 → 1 on Esc. The test was written red first and
69
+ passed unedited after the fix.
70
+ [2] `lib/okf/tui/app.rb` — `handle_escape`.
@@ -0,0 +1,57 @@
1
+ ---
2
+ type: Concept
3
+ title: A Dead Filter Offers the Wider Search
4
+ description: Filtering reads metadata in one bundle and searching reads bodies across all of them, so a filter that matches nothing offers the search rather than leaving a dead end.
5
+ tags: [ux, search]
6
+ timestamp: 2026-08-13
7
+ ---
8
+
9
+ # Overview
10
+
11
+ `/` filters the list you are in — bundles by slug or path, browse by title, id,
12
+ type or tag, graph by type, tag or concept id. When it matches nothing, the empty
13
+ result is not a dead end: `Enter` takes the term to the search view and runs it
14
+ across every scoped bundle.
15
+
16
+ # Why the escalation is honest
17
+
18
+ The two are not the same search narrowed differently — they read different things
19
+ in different places:
20
+
21
+ | | reads | covers |
22
+ |---|---|---|
23
+ | filter | metadata (title, id, type, tag) | the current bundle |
24
+ | search | bodies, ranked | every bundle in scope |
25
+
26
+ So "no concept here is *called* that" and "nothing anywhere *says* that" are
27
+ genuinely different answers, and a reader who got the first one almost always
28
+ wants the second. Making them ask again in another view — retyping the term — is
29
+ the whole friction the escalation removes.
30
+
31
+ It is offered rather than automatic. The jump changes what you are looking at and
32
+ runs work, so it stays an accepted suggestion, consistent with
33
+ [search submitting rather than following typing](/interaction/deferred-search.md).
34
+
35
+ # The registry filter escalates too
36
+
37
+ A filter in the bundles view looks through a dozen slugs and the group names
38
+ beside them, which is a narrower thing than it looks: a term matching none of
39
+ them is usually a question about what the bundles *say*, not about what one is
40
+ called. `Enter` there takes the term to the search view over every bundle, the
41
+ same key doing the same thing.
42
+
43
+ **Both panes have to be empty, not just the focused one.** A filter matching a
44
+ group and no bundle has found something, and the first cut of this read only the
45
+ bundles pane — so `Enter` escalated on the keystroke that was accepting the
46
+ filter, taking the filter, the view and the group the reader was pointing at with
47
+ it. "The filter found nothing" is a claim about the whole view.
48
+
49
+ # Citations
50
+
51
+ [1] `lib/okf/tui/views.rb` — `escalation_panel`.
52
+ [2] `lib/okf/tui/app.rb` — `filter_found_nothing?`, which is where the two views'
53
+ conditions live side by side.
54
+ [3] `test/integration/search_test.rb` — "a filter that matches nothing offers
55
+ the wider search".
56
+ [4] `test/integration/groups_test.rb` — "a filter matching a group but no bundle
57
+ has found something".
@@ -0,0 +1,82 @@
1
+ ---
2
+ type: Decision
3
+ title: Following a Link Out of the Page
4
+ description: The picker is a mode rather than inline hints, the directory link okf declines to resolve, and the count it deliberately disagrees with.
5
+ tags: [ux, keys, okf-coupling]
6
+ timestamp: 2026-07-19
7
+ ---
8
+
9
+ # Overview
10
+
11
+ `f` lists the markdown links leaving the open document; `1`–`9` or `Enter`
12
+ follows one. The list comes from `OKF::Markdown::Links` — the same extraction
13
+ `Bundle::Graph` builds its edges with and the validator warns on — so this walks
14
+ the graph okf already computed rather than reading the body for link syntax. It
15
+ stays inside [invents-no-analysis](/decisions/invents-no-analysis.md) by asking,
16
+ not parsing.
17
+
18
+ # A picker, not inline hints
19
+
20
+ The obvious design is the browser one: light up each link in the body and label
21
+ it. It was not built, and the reason is
22
+ [markdown-rendering-trap](/rendering/markdown-rendering-trap.md) — the body on
23
+ screen is tty-markdown's output, which has already rewritten and coloured the
24
+ link text. Anchoring a hint to it means pattern-matching a *render artifact*, and
25
+ the render is the least stable thing here.
26
+
27
+ The link list is a value instead: a plain array of resolved targets, which a view
28
+ turns into rows and a check asserts on without a terminal. It replaces the body
29
+ in the detail pane rather than overlaying it, so the header above stays put and
30
+ nothing touches `@detail_scroll` — Esc puts the page back exactly where it was.
31
+
32
+ # The picker owns the digits
33
+
34
+ It is a mode, dispatched before the view-switch keys, which is the whole reason
35
+ it is a mode and not a pane: while it is open `1` picks a link, and everywhere
36
+ else `1` is view one. That ordering is the rule in
37
+ [key-routing](/interaction/key-routing.md), and the picker sits innermost — Esc
38
+ closes it and leaves a running find still lit, which is
39
+ [esc-peels-one-layer](/interaction/esc-peels-one-layer.md) applied to one more
40
+ layer.
41
+
42
+ # The directory link okf will not resolve
43
+
44
+ `Links.resolve` returns nil for a target ending in `/`, and is right to: a
45
+ directory is not a graph edge and the validator has nothing to check about it.
46
+ But `[Decisions](decisions/)` is how *every* index.md points at its area, so
47
+ reading okf's answer literally left the bundle's front door — the root index,
48
+ which §6 makes the way in — as the one page with nothing to follow. Measured, not
49
+ guessed: 0 of 5 links resolved there, against 5 of 5 in `interaction/index.md`.
50
+
51
+ So a nil resolution whose raw target ends in `/` is retried as
52
+ `<target>index.md`, and kept only if that path is in `bundle.reserved`. That is a
53
+ lookup in a list okf handed over — no directory is walked, no markdown is read,
54
+ and a directory with no index stays unfollowable. The judgement: this is
55
+ *navigation*, which the TUI owns, not *analysis*, which it must not invent. The
56
+ stricter reading is that resolution belongs upstream in okf, and if it lands
57
+ there this should collapse into calling it.
58
+
59
+ # The count it disagrees with
60
+
61
+ The detail header shows `links →N` from the catalog, and the picker shows its own
62
+ count, and **they do not always match**. The header counts graph edges:
63
+ concept→concept, deduped, self-links dropped. The picker counts what a reader can
64
+ follow, which also includes reserved files and targets nothing has been written
65
+ at yet. In the `okf-docs` fixture, `overview` reads 16 against the picker's 17 —
66
+ the extra one is a link to `design/index.md`, which the graph has no node for.
67
+ Both numbers are right about different questions, so the picker carries its own
68
+ label rather than being reconciled to the header.
69
+
70
+ # The trail came free
71
+
72
+ `Backspace` pops a stack pushed inside `open_concept` and `open_reserved` — the
73
+ two functions every jump already went through. Opening a search hit and following
74
+ a concept out of the graph became reversible without either being touched, which
75
+ is why the stack lives there rather than in the picker.
76
+
77
+ # Citations
78
+
79
+ [1] `lib/okf/tui/model.rb` — `links_for`, `resolve_target`, `describe_link`.
80
+ [2] `lib/okf/tui/app.rb` — `handle_follow`, `follow_selected`, `push_trail`, `back`.
81
+ [3] `test/integration/links_test.rb` — the area-link case is the one that would
82
+ otherwise have been silently empty.
@@ -0,0 +1,12 @@
1
+ # Interaction
2
+
3
+ The keyboard model, and the UX decisions that were arrived at by getting them
4
+ wrong first.
5
+
6
+ * [Key Routing and Its Modes](key-routing.md) - The dispatch order, why `/` starts every text field, and the `case`-guard trap for letters that mean two things.
7
+ * [Esc Peels One Layer](esc-peels-one-layer.md) - Esc ends the innermost thing only — the rule that stops a find from costing you the file you were reading.
8
+ * [Search Submits, It Does Not Follow Typing](deferred-search.md) - Why Enter runs the search, and the regression that renders identically to the correct behaviour.
9
+ * [Which Registry a Session Is On](which-registry.md) - okf resolves a project-local registry before the global one; being the single verb that disagreed was a silent wrong answer rather than an error.
10
+ * [Active Bundle and Scope Are Two Axes](cross-bundle-scope.md) - Reading one bundle while searching many, and why reconciliation keys on the directory.
11
+ * [A Dead Filter Offers the Wider Search](filter-escalates-to-search.md) - Filter reads metadata in one bundle, search reads bodies across all of them.
12
+ * [Following a Link Out of the Page](following-links.md) - A picker rather than inline hints, the directory link okf declines to resolve, and the count it deliberately disagrees with.
@@ -0,0 +1,84 @@
1
+ ---
2
+ type: Reference
3
+ title: Key Routing and Its Modes
4
+ description: handle dispatches through modes before the global keys, which is what keeps digits navigating everywhere, and the guard-fallback trap that a Ruby case statement sets for shared letters.
5
+ tags: [ux, keys]
6
+ timestamp: 2026-07-18
7
+ ---
8
+
9
+ # The order
10
+
11
+ `handle` tries modes in order, innermost first, and only then the global keys:
12
+
13
+ ```
14
+ prompt → link picker → find → filter → query field → view switch (1-6) → global keys
15
+ ```
16
+
17
+ Each mode returns early, so while one owns the keyboard the layers under it never
18
+ see the key.
19
+
20
+ The link picker is the clearest case of why the order is what it is: it sits
21
+ above the view switch so that `1` picks a link while it is open, and means view
22
+ one everywhere else — see [following-links](/interaction/following-links.md).
23
+
24
+ # Nothing grabs the field on arrival
25
+
26
+ The rule that shapes the whole scheme: **arriving at a view never gives its text
27
+ field focus.** Typing starts on `/`, everywhere, in every view that has anything
28
+ to look through.
29
+
30
+ The alternative was tried and is worse. A search view that takes focus on arrival
31
+ swallows every printable key, so pressing `3` then `4` types "4" into the query
32
+ instead of switching views — the number keys stop being navigation the moment you
33
+ land somewhere that can type. Requiring `/` costs one keystroke and buys `1`–`6`
34
+ meaning the same thing from everywhere.
35
+
36
+ In the search view specifically, `Enter` submits and `Esc` releases the field
37
+ *without leaving the view*, so the results stay reachable — see
38
+ [esc-peels-one-layer](/interaction/esc-peels-one-layer.md) and
39
+ [deferred-search](/interaction/deferred-search.md).
40
+
41
+ What `/` looks through depends on what has focus: a list is filtered, a document
42
+ is searched within. When a list filter comes back empty it
43
+ [offers the wider search](/interaction/filter-escalates-to-search.md) rather than
44
+ stopping there.
45
+
46
+ # The guard trap
47
+
48
+ Two letters do different things in different views: `n` is "next match" while
49
+ reading and "rename" in the bundles view; `N` is "previous match" and "scope
50
+ none".
51
+
52
+ A Ruby `case` branch matches **whether or not its guard holds** — a `when "n"`
53
+ with a failing guard does not fall through to the next branch, it matches and
54
+ does nothing. So the shared letters must hand the key back explicitly:
55
+
56
+ ```ruby
57
+ when "n" then findable? ? step_match(1) : fallback(key)
58
+ when "N" then findable? ? step_match(-1) : fallback(key)
59
+ ```
60
+
61
+ Without `fallback`, `n` and `N` were simply swallowed in the bundles view and
62
+ rename was unreachable. Any new letter that means two things in two views needs
63
+ the same treatment.
64
+
65
+ Worth knowing that a find now survives until `Esc` rather than until `Enter`, so
66
+ the window in which `n` means "next match" is longer than it used to be.
67
+
68
+ # One key that spans two presses
69
+
70
+ `q` quits, but only as `q q`. A single press ended the session on one stray
71
+ keystroke with nothing to undo it, so the first press arms and says so on the
72
+ status line, and the *next key* either confirms or cancels.
73
+
74
+ The disarm is what makes it correct, and it is placed above the mode handlers on
75
+ purpose — typing `q` into a filter or a query has to cancel the arming, or the
76
+ chord leaks across a text field and `q` still quits on its own two keystrokes
77
+ later. That is the check worth keeping: an arming that never lets go renders
78
+ identically to the fixed behaviour until the pair is pressed apart.
79
+
80
+ `Ctrl-c` stays single. An escape hatch that needs confirming is not one.
81
+
82
+ # Citations
83
+
84
+ [1] `lib/okf/tui/app.rb` — `handle`, `fallback`, `KEY_VIEWS`.