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,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "okf"
4
+ require "okf/registry"
5
+
6
+ module OKF::TUI
7
+ # Every bundle the session can see, which one is active, and which ones a
8
+ # search covers.
9
+ #
10
+ # Two ways in, mirroring what `okf server` accepts: named directories are
11
+ # ad-hoc and never touch the registry, and with no directories the registry
12
+ # itself is the workspace. Registering stays an explicit act — an ad-hoc look
13
+ # at two bundles should not enrol them in the user's durable list.
14
+ class Workspace
15
+ # One bundle in the workspace. `model` is nil when the directory is missing
16
+ # or unreadable, and `error` says which — so a row explains itself instead of
17
+ # silently vanishing from the list.
18
+ class Entry
19
+ attr_reader :slug, :dir
20
+ attr_accessor :model, :error
21
+
22
+ def initialize(slug:, dir:, default:, registered:)
23
+ @slug = slug
24
+ @dir = dir
25
+ @default = default
26
+ @registered = registered
27
+ end
28
+
29
+ def default?
30
+ @default
31
+ end
32
+
33
+ def registered?
34
+ @registered
35
+ end
36
+
37
+ def loaded?
38
+ !model.nil?
39
+ end
40
+
41
+ def concepts
42
+ loaded? ? model.concept_count : 0
43
+ end
44
+ end
45
+
46
+ # One registry group (okf 1.12): a slug naming a list of members — bundle or
47
+ # group slugs, so they nest — which resolves recursively, path-deduped, to
48
+ # bundle leaves.
49
+ #
50
+ # `members` is what the registry file says, verbatim, and `bundles` is what
51
+ # okf resolved it to. Both are shown, because they answer different questions:
52
+ # the members are what an edit would change, the leaves are what a search
53
+ # would cover, and for a nested group those are not the same list.
54
+ class Group
55
+ attr_reader :slug, :members, :bundles
56
+
57
+ def initialize(slug:, members:, bundles:, cyclic:)
58
+ @slug = slug
59
+ @members = members
60
+ @bundles = bundles
61
+ @cyclic = cyclic
62
+ end
63
+
64
+ # A hand-edited registry can name a cycle, which okf reports by declining to
65
+ # resolve rather than by looping. Nothing here can repair it, so the row says
66
+ # so and refuses to be scoped — the alternative is a group that silently
67
+ # covers no bundles.
68
+ def cyclic?
69
+ @cyclic
70
+ end
71
+
72
+ def size
73
+ bundles.length
74
+ end
75
+ end
76
+
77
+ attr_reader :entries, :registry, :home, :active_slug
78
+
79
+ # +cwd+ is what opts this workspace into registry discovery, and it is
80
+ # deliberately not defaulted to Dir.pwd: okf draws the same line, where only
81
+ # its CLI passes a cwd and a library caller stays on the global registry. A
82
+ # default here would make the registry an embedding app reads depend on the
83
+ # directory its process happens to be in, and would let the suite discover a
84
+ # `.okf-registry.json` from wherever `rake` was run.
85
+ #
86
+ # +ref_slugs+ maps a resolved directory to the slug the @ref named it by, so a
87
+ # session built from refs keeps the names the user typed. See Refs#slugs.
88
+ def initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil)
89
+ @home = home
90
+ @cwd = cwd
91
+ @dirs = Array(dirs)
92
+ @ref_slugs = ref_slugs || {}
93
+ load_entries
94
+ end
95
+
96
+ # A registry-backed workspace is the one that can be configured; a workspace
97
+ # of named directories has no registry to write to.
98
+ def registry_backed?
99
+ @dirs.empty?
100
+ end
101
+
102
+ # The registry file this session is on — the discovered project-local one when
103
+ # there is one, else the global $OKF_HOME one. Asked of the registry rather
104
+ # than recomputed from `home`, because Registry.path only ever names the global
105
+ # file and would report the wrong one out of a project directory: the header
106
+ # would print a path the session is not reading.
107
+ def registry_path
108
+ (registry || open_registry).path
109
+ end
110
+
111
+ def empty?
112
+ entries.empty?
113
+ end
114
+
115
+ def entry(slug)
116
+ entries.find { |candidate| candidate.slug == slug }
117
+ end
118
+
119
+ # The registry's groups. Empty for an ad-hoc workspace, which has no registry
120
+ # to have groups in.
121
+ #
122
+ # Read from okf rather than derived: `groups_listing` is what `okf registry
123
+ # list` prints and `expand` is what `okf search @group` resolves, so a group
124
+ # means the same set here as it does at the command line.
125
+ def groups
126
+ @groups ||= registry_backed? ? registry_groups : []
127
+ end
128
+
129
+ def group(slug)
130
+ groups.find { |candidate| candidate.slug == slug }
131
+ end
132
+
133
+ def active
134
+ entry(@active_slug)
135
+ end
136
+
137
+ # The active bundle's Model, or nil when nothing is loadable. Every
138
+ # single-bundle view reads through this.
139
+ def model
140
+ active&.model
141
+ end
142
+
143
+ def switch(slug)
144
+ return false unless entry(slug)&.loaded?
145
+
146
+ @active_slug = slug
147
+ true
148
+ end
149
+
150
+ # ── scope: which bundles a search covers ────────────────────────────────
151
+
152
+ # Kept in workspace order and filtered to slugs that still exist, so a
153
+ # reload after a remove cannot leave a stale slug in the scope.
154
+ def scope
155
+ entries.map(&:slug).select { |slug| @scope.include?(slug) }
156
+ end
157
+
158
+ def scoped?(slug)
159
+ @scope.include?(slug)
160
+ end
161
+
162
+ def toggle_scope(slug)
163
+ @scope.include?(slug) ? @scope.delete(slug) : @scope << slug
164
+ end
165
+
166
+ def scope_all
167
+ @scope = entries.map(&:slug)
168
+ end
169
+
170
+ def scope_none
171
+ @scope = []
172
+ end
173
+
174
+ def scope_only(slug)
175
+ @scope = [ slug ]
176
+ end
177
+
178
+ # Scope the search to a group — the reason a group is worth showing here at
179
+ # all. `okf search @mkt` merges exactly these bundles into one ranking; this is
180
+ # the same set, named the same way, reached with one key instead of retyping
181
+ # the members.
182
+ #
183
+ # Returns a message for the status line, like the config writes do, because
184
+ # each of the three ways this can decline to do anything needs saying: a
185
+ # scope that silently did not change reads as a broken key.
186
+ def scope_group(slug)
187
+ group = group(slug)
188
+ return "no such group: @#{slug}" if group.nil?
189
+ return "@#{slug} names a cycle in #{registry.path} — okf cannot resolve it" if group.cyclic?
190
+ return "@#{slug} resolves to no bundle here" if group.bundles.empty?
191
+
192
+ # A copy: `toggle_scope` mutates the scope in place, and the group is a
193
+ # description of the registry, not a scratch list.
194
+ @scope = group.bundles.dup
195
+ "search scope: @#{slug} — #{group.size} #{group.size == 1 ? "bundle" : "bundles"}"
196
+ end
197
+
198
+ # Ranked search across every scoped bundle, merged into one ordered list.
199
+ # They share one index on purpose: BM25 weighs a term by how rare it is in
200
+ # the corpus, so per-bundle indexes would score the same match differently
201
+ # depending on which bundle it came from. One index makes one corpus — the
202
+ # same thing `okf search @all` does.
203
+ #
204
+ # The mode is what chooses okf's engine, by declaring the capability the query
205
+ # needs rather than by naming an engine:
206
+ #
207
+ # :fuzzy `fuzzy: true` → the full-text index. Ranked BM25, typo-tolerant.
208
+ # :text nothing → the scan, okf's own default. Raw substring.
209
+ # :regexp `regexp: true` → the scan, as a pattern.
210
+ #
211
+ # All three are reachable because the index and the scan disagree *by design*,
212
+ # and each is wrong for what the other is right for. okf documents the index's
213
+ # limits precisely: its tokenizer splits on punctuation, so `7.2.0` indexes as
214
+ # `7`, `2`, `0`, and a backtick is not punctuation, so a word inside a code span
215
+ # indexes as `` `minifts` `` and the query `minifts` does not match it. Measured
216
+ # on okf's own bundle, the index finds three of the five concepts that say
217
+ # minifts, and returns fourteen for OKF_HOME where the scan returns five.
218
+ # Offering only the index left the terms glued to symbols — a constant, an env
219
+ # var, a version — unfindable, with nothing on screen saying so.
220
+ def search(query, mode: :fuzzy)
221
+ @search_error = nil
222
+ terms = query.to_s.split(/\s+/).reject(&:empty?)
223
+ return [] if terms.empty?
224
+
225
+ corpus = search_corpus
226
+ return [] if corpus.nil?
227
+
228
+ run_search(corpus, terms, mode)
229
+ rescue RegexpError => e
230
+ # A bad pattern is the user's typo, not a broken install, and it must not
231
+ # land in the blanket rescue below — "no matches" for an unparseable regexp
232
+ # is the silent-wrong-answer shape this view keeps having to avoid.
233
+ @search_error = "bad pattern: #{e.message}"
234
+ []
235
+ rescue OKF::Bundle::Search::UnsupportedQuery => e
236
+ @search_error = e.message
237
+ []
238
+ rescue StandardError
239
+ []
240
+ end
241
+
242
+ # Set when a query could not be answered *and the reason is worth showing*.
243
+ # nil after any search that ran, so it never outlives the query it describes.
244
+ attr_reader :search_error
245
+
246
+ # ── config: every registry write lands here ─────────────────────────────
247
+ #
248
+ # Each returns a message for the status line and reloads, so what the screen
249
+ # shows next is what the file now says rather than what the in-memory list
250
+ # was talked into believing.
251
+
252
+ def add(dir)
253
+ return not_registry_backed unless registry_backed?
254
+
255
+ added = registry.add(File.expand_path(dir.to_s.strip))
256
+ reload
257
+ "registered @#{added.slug} → #{added.path}"
258
+ rescue OKF::Error => e
259
+ "could not add: #{e.message}"
260
+ end
261
+
262
+ def remove(slug)
263
+ return not_registry_backed unless registry_backed?
264
+
265
+ registry.remove(slug)
266
+ reload
267
+ "removed @#{slug} from the registry (the bundle itself is untouched)"
268
+ rescue OKF::Error => e
269
+ "could not remove: #{e.message}"
270
+ end
271
+
272
+ def make_default(slug)
273
+ return not_registry_backed unless registry_backed?
274
+
275
+ registry.default = slug
276
+ reload
277
+ "@#{slug} is now the default"
278
+ rescue OKF::Error => e
279
+ "could not set default: #{e.message}"
280
+ end
281
+
282
+ def rename(old_slug, new_slug)
283
+ return not_registry_backed unless registry_backed?
284
+
285
+ new_slug = new_slug.to_s.strip
286
+ return "rename cancelled: no name given" if new_slug.empty?
287
+
288
+ renamed = registry.rename(old_slug, new_slug)
289
+ reload
290
+ "@#{old_slug} is now @#{renamed.slug}"
291
+ rescue OKF::Error => e
292
+ "could not rename: #{e.message}"
293
+ end
294
+
295
+ # ── groups: the writes okf's `registry group` / `ungroup` make ──────────
296
+ #
297
+ # A group is registry configuration in exactly the sense a slug rename is, so
298
+ # it belongs on the same side of the line as the writes above — see
299
+ # [registry-write-boundary]. Members come from the *scope* rather than from a
300
+ # typed list: `◉` already means "these bundles" in this view, so the gesture is
301
+ # toggle what you want, then name it.
302
+ #
303
+ # okf owns the cascades. `set_group` creates-or-adds and refuses a cycle,
304
+ # `unset_group_members` deletes a group it empties, and `rename`/`remove` span
305
+ # a group slug and cascade through every member list — none of which is
306
+ # reimplemented here.
307
+
308
+ def create_group(slug, members)
309
+ return not_registry_backed unless registry_backed?
310
+
311
+ slug = slug.to_s.strip
312
+ return "group cancelled: no name given" if slug.empty?
313
+ return "group cancelled: no bundles in scope to name" if members.empty?
314
+
315
+ group = registry.set_group(slug, refs(members))
316
+ reload
317
+ "@#{group.slug} names #{count(members.length)}"
318
+ rescue OKF::Error => e
319
+ "could not create the group: #{e.message}"
320
+ end
321
+
322
+ def add_to_group(slug, members)
323
+ return not_registry_backed unless registry_backed?
324
+ return "nothing in scope to add" if members.empty?
325
+
326
+ registry.set_group(slug, refs(members))
327
+ reload
328
+ # One member named, several counted: `+` adds the row under a cursor and the
329
+ # line has to say *which* row landed, while `c` adds a whole scope and naming
330
+ # every slug would be a list, not a message.
331
+ total = count(group(slug)&.size.to_i)
332
+ members.length == 1 ? "@#{members.first} joined @#{slug} — #{total}" : "@#{slug} now names #{total}"
333
+ rescue OKF::Error => e
334
+ "could not add to @#{slug}: #{e.message}"
335
+ end
336
+
337
+ def remove_from_group(slug, members)
338
+ return not_registry_backed unless registry_backed?
339
+ return "nothing in scope to remove" if members.empty?
340
+
341
+ registry.unset_group_members(slug, refs(members))
342
+ reload
343
+ # okf deletes a group its last member left, so say so rather than leave the
344
+ # user looking for a row that is gone.
345
+ return "@#{slug} had nothing left, so it is gone" if group(slug).nil?
346
+
347
+ total = count(group(slug).size)
348
+ members.length == 1 ? "@#{members.first} left @#{slug} — #{total}" : "@#{slug} now names #{total}"
349
+ rescue OKF::Error => e
350
+ "could not remove from @#{slug}: #{e.message}"
351
+ end
352
+
353
+ # Re-read from disk, keeping the active bundle and the scope wherever they
354
+ # still resolve.
355
+ def reload
356
+ previous_active = @active_slug
357
+ previous_scope = @scope
358
+
359
+ load_entries
360
+
361
+ @active_slug = previous_active if entry(previous_active)&.loaded?
362
+ @active_slug ||= first_loaded_slug
363
+ kept = previous_scope.select { |slug| entry(slug) }
364
+ @scope = kept.empty? ? entries.map(&:slug) : kept
365
+ end
366
+
367
+ private
368
+
369
+ def not_registry_backed
370
+ "these bundles were named on the command line — there is no registry to change"
371
+ end
372
+
373
+ # Slugs as okf's group verbs take them. `@` is the ref grammar's own marker,
374
+ # and passing it keeps these calls readable as the CLI commands they mirror.
375
+ def refs(slugs)
376
+ slugs.map { |slug| "@#{slug}" }
377
+ end
378
+
379
+ def count(total)
380
+ "#{total} #{total == 1 ? "bundle" : "bundles"}"
381
+ end
382
+
383
+ # Explicit branches rather than a splatted options hash: three named calls read
384
+ # as the three engines they select, and the floor is Ruby 2.4.
385
+ def run_search(corpus, terms, mode)
386
+ case mode
387
+ when :regexp then OKF::Bundle::Search.with(corpus, terms, regexp: true)
388
+ when :text then OKF::Bundle::Search.with(corpus, terms)
389
+ else OKF::Bundle::Search.with(corpus, terms, fuzzy: true)
390
+ end
391
+ end
392
+
393
+ def scoped_pairs
394
+ entries.select { |entry| entry.loaded? && scoped?(entry.slug) }
395
+ .map { |entry| [ entry.slug, entry.model.bundle ] }
396
+ end
397
+
398
+ # The corpus behind the search view, held across queries.
399
+ #
400
+ # `Search.across` rebuilds everything per call — the documents *and* the
401
+ # full-text index — which is the right trade for the CLI it was measured for:
402
+ # one question, then the process exits. A TUI is the other case, the one okf
403
+ # 1.11.0 added `prepare`/`with` for and `okf server` already uses. Measured
404
+ # over five registered bundles, 129 concepts between them: **392 ms** for the
405
+ # first query, which builds the corpus, then **12–16 ms** for every one after.
406
+ # Rebuilding, every query cost the 392 ms.
407
+ #
408
+ # Built on first use rather than at load: with a whole registry open, a session
409
+ # that never searches should not pay to index every bundle nobody looked at —
410
+ # the same reason Model memoizes its analysis instead of computing it eagerly.
411
+ # No `engine:` for the same reason; that argument only moves the index build
412
+ # earlier, and there is no boot here to move it into.
413
+ #
414
+ # Keyed on the scoped slugs, and dropped outright by #load_entries. Both
415
+ # matter, and for different reasons: the key catches a scope the user changed,
416
+ # while the drop catches a reload, after which the models are freshly read
417
+ # objects and a corpus built from the old ones describes a bundle that may no
418
+ # longer be on disk. okf takes the same care in its hub, and for the stated
419
+ # reason — a held index outliving the set it was built from is a wrong answer
420
+ # rather than a slow one.
421
+ def search_corpus
422
+ pairs = scoped_pairs
423
+ return nil if pairs.empty?
424
+
425
+ key = pairs.map { |slug, _bundle| slug }
426
+ return @corpus if @corpus && @corpus_key == key
427
+
428
+ # Assigned only on success, so a failed build is retried rather than
429
+ # memoized as an empty result — #search rescues into "no matches", which is
430
+ # the wrong lasting answer for a corpus that could not be built.
431
+ corpus = OKF::Bundle::Search.prepare(pairs)
432
+ @corpus_key = key
433
+ @corpus = corpus
434
+ end
435
+
436
+ # Discovery runs once, on the first load; every reload after a write goes
437
+ # through Registry#reopen instead. That is okf's own instruction, and the
438
+ # reason is the relative paths a local registry stores: `Registry.new(path)`
439
+ # drops the +relative_base+ a discovered file carries, and then every in-tree
440
+ # bundle reads as "folder is gone". Re-discovering would find the same file,
441
+ # but reopening says what is meant — re-read this registry, anchored as it was.
442
+ def open_registry
443
+ OKF::Registry.load(home: home, cwd: @cwd)
444
+ end
445
+
446
+ def load_entries
447
+ @registry = (registry ? registry.reopen : open_registry) if registry_backed?
448
+ @entries = registry_backed? ? registry_entries : ad_hoc_entries
449
+ # See #search_corpus: the entries about to be built are freshly read, so a
450
+ # corpus held over from the previous ones is describing bundles this session
451
+ # no longer has. The groups come off the same re-read registry, so a write
452
+ # that cascaded through a member list shows its result rather than the list
453
+ # memory remembers.
454
+ @corpus = nil
455
+ @corpus_key = nil
456
+ @groups = nil
457
+ @scope = @entries.map(&:slug)
458
+ @active_slug = first_loaded_slug
459
+ end
460
+
461
+ def first_loaded_slug
462
+ @entries.find(&:loaded?)&.slug
463
+ end
464
+
465
+ def registry_entries
466
+ registry.listing.map do |row|
467
+ build_entry(slug: row[:slug], dir: row[:dir], default: row[:default],
468
+ registered: true, missing: row[:missing])
469
+ end
470
+ end
471
+
472
+ # okf reports an unresolvable group by putting nil in `resolved` — that is its
473
+ # signal for a hand-edited cycle, which it declines to walk rather than loop
474
+ # on. Asking `expand` again for the leaf slugs is guarded the same way, since
475
+ # `groups_listing` swallowed the error to compute the count.
476
+ def registry_groups
477
+ registry.groups_listing.map do |row|
478
+ bundles = begin
479
+ row[:resolved] && registry.expand(row[:slug]).map(&:slug)
480
+ rescue OKF::Error
481
+ nil
482
+ end
483
+
484
+ Group.new(slug: row[:slug], members: row[:members], bundles: bundles || [],
485
+ cyclic: bundles.nil?)
486
+ end
487
+ end
488
+
489
+ # Ad-hoc directories are slugged the way the server slugs its own ephemeral
490
+ # mounts, and deduped, so two directories sharing a basename stay distinct.
491
+ #
492
+ # A directory that arrived as an @ref keeps the slug it is registered under.
493
+ # Deriving it from the basename instead would name it `.okf` — the conventional
494
+ # container, and therefore the basename of nearly every registered bundle — so
495
+ # `okf-tui @okf-site @okf-mkt` would list @okf and @okf-2, having discarded both
496
+ # names that were typed. okf hit exactly this in its hub and fixed it the same
497
+ # way. Still deduped: a slug is unique in a registry, but a ref-named bundle and
498
+ # a plain directory in the same argv can still collide.
499
+ def ad_hoc_entries
500
+ taken = []
501
+ @dirs.each_with_index.map do |dir, index|
502
+ expanded = File.expand_path(dir)
503
+ registered = @ref_slugs[expanded]
504
+ slug = OKF::Registry.dedupe(registered || File.basename(expanded), taken) # dedupe slugifies
505
+ taken << slug
506
+ build_entry(slug: slug, dir: expanded, default: index.zero?,
507
+ registered: !registered.nil?, missing: !File.directory?(expanded))
508
+ end
509
+ end
510
+
511
+ def build_entry(slug:, dir:, default:, registered:, missing:)
512
+ entry = Entry.new(slug: slug, dir: dir, default: default, registered: registered)
513
+
514
+ if missing
515
+ entry.error = "directory is gone"
516
+ else
517
+ begin
518
+ entry.model = Model.new(dir, slug: slug)
519
+ rescue StandardError => e
520
+ entry.error = e.message
521
+ end
522
+ end
523
+
524
+ entry
525
+ end
526
+ end
527
+ end
data/lib/okf/tui.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "okf"
4
+
5
+ require_relative "tui/version"
6
+
7
+ module OKF
8
+ # A terminal UI over OKF bundles: read one, switch between many, configure the
9
+ # registry, and search across all of them at once.
10
+ #
11
+ # `require "okf/tui"` loads the library only — the model, the workspace, and
12
+ # the screens. The argv-facing shell (OKF::TUI::CLI, and its option parsing)
13
+ # loads on demand: `okf tui` requires it from inside the plugin's #call, and so
14
+ # must any test that drives it. An embedding app never pays for the
15
+ # command-line machinery, and neither does an `okf lint` that merely made okf
16
+ # read this gem's plugin file.
17
+ #
18
+ # The split okf enforces between its pure core and its shell is what makes this
19
+ # small: OKF::Bundle::Reader and OKF::Registry are the only parts that touch
20
+ # disk, and every answer on screen is a pure call on the resulting in-memory
21
+ # bundles — catalog, graph, validate, lint, Bundle::Search. The TUI is one more
22
+ # shell over the same core the `okf` CLI and the graph server already use, and
23
+ # it invents no analysis of its own.
24
+ module TUI
25
+ class Error < OKF::Error
26
+ end
27
+
28
+ # What search needs from okf, checked rather than assumed: the engine facade
29
+ # that merges several bundles into one ranked corpus (`across`, okf 1.9) and
30
+ # the prepared corpus a long-lived caller queries instead of rebuilding
31
+ # (`prepare`/`with`, okf 1.11).
32
+ #
33
+ # The gemspec's floor already requires both, so this is not about an old
34
+ # dependency — it is about a *second* okf installed ahead of the intended one
35
+ # on the load path, which no version constraint can prevent.
36
+ #
37
+ # It earns a check of its own because its absence is silent. Workspace#search
38
+ # rescues a failed search into an empty result — right for a query okf cannot
39
+ # parse, wrong for a method that is not there, because then every search
40
+ # answers "no matches" and the screen reads as an empty bundle rather than a
41
+ # broken install. The CLI refuses to boot instead of showing that screen.
42
+ def self.search_capable?
43
+ %i[across prepare with].all? { |name| OKF::Bundle::Search.respond_to?(name) }
44
+ end
45
+
46
+ # What §5 needs from okf, on the same argument and for a sharper reason.
47
+ #
48
+ # Every screen here now reads the v0.2 families — a concept's provenance in
49
+ # browse, the spec version and the trust/status posture on health, two facets
50
+ # in the graph — and each of them is a question only okf can answer:
51
+ # `Bundle#okf_version` for what the bundle declares, and
52
+ # `Bundle::RowFilter.shows_trust?` for whether a derived tier is one to claim.
53
+ #
54
+ # Against an okf without them the failure is a NoMethodError from inside a
55
+ # frame, which is a crash where the screen should have been. Refusing at boot
56
+ # says which gem is wrong instead, and says it once.
57
+ def self.spec_capable?
58
+ OKF::Bundle.method_defined?(:okf_version) &&
59
+ defined?(OKF::Bundle::RowFilter) &&
60
+ OKF::Bundle::RowFilter.respond_to?(:shows_trust?)
61
+ end
62
+
63
+ # ── layout: the primitives every screen draws through ──
64
+ require_relative "tui/ui"
65
+
66
+ # ── domain: one bundle, and the set of them a session can see ──
67
+ require_relative "tui/model"
68
+ require_relative "tui/workspace"
69
+
70
+ # ── screens: pure row builders — no view writes to the terminal ──
71
+ require_relative "tui/views"
72
+
73
+ # ── shell: the interactive loop ──
74
+ require_relative "tui/app"
75
+ end
76
+ end