okf-tui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.okf/decisions/index.md +12 -0
- data/.okf/decisions/invents-no-analysis.md +53 -0
- data/.okf/decisions/no-version-ceilings.md +69 -0
- data/.okf/decisions/okf-capability-drift.md +120 -0
- data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
- data/.okf/decisions/registry-write-boundary.md +175 -0
- data/.okf/decisions/ruby-floor.md +59 -0
- data/.okf/decisions/search-facade-coupling.md +146 -0
- data/.okf/decisions/undeclared-width-dependency.md +73 -0
- data/.okf/index.md +28 -0
- data/.okf/interaction/cross-bundle-scope.md +61 -0
- data/.okf/interaction/deferred-search.md +49 -0
- data/.okf/interaction/esc-peels-one-layer.md +70 -0
- data/.okf/interaction/filter-escalates-to-search.md +57 -0
- data/.okf/interaction/following-links.md +82 -0
- data/.okf/interaction/index.md +12 -0
- data/.okf/interaction/key-routing.md +84 -0
- data/.okf/interaction/which-registry.md +85 -0
- data/.okf/log.md +38 -0
- data/.okf/rendering/ansi-aware-width.md +74 -0
- data/.okf/rendering/index.md +8 -0
- data/.okf/rendering/markdown-rendering-trap.md +63 -0
- data/.okf/rendering/status-vocabulary.md +45 -0
- data/.okf/rendering/whole-frame-painting.md +52 -0
- data/.okf/testing/ci-matrix.md +80 -0
- data/.okf/testing/headless-frames.md +74 -0
- data/.okf/testing/index.md +8 -0
- data/.okf/testing/pty-test.md +73 -0
- data/CHANGELOG.md +239 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +194 -0
- data/lib/okf/plugin.rb +63 -0
- data/lib/okf/tui/app.rb +1908 -0
- data/lib/okf/tui/cli.rb +154 -0
- data/lib/okf/tui/model.rb +410 -0
- data/lib/okf/tui/refs.rb +63 -0
- data/lib/okf/tui/ui.rb +308 -0
- data/lib/okf/tui/version.rb +7 -0
- data/lib/okf/tui/views.rb +1648 -0
- data/lib/okf/tui/workspace.rb +527 -0
- data/lib/okf/tui.rb +76 -0
- metadata +229 -0
|
@@ -0,0 +1,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`.
|