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,1648 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-markdown"
4
+ require_relative "ui"
5
+
6
+ module OKF::TUI
7
+ # The screens. Each renders into a fixed rectangle and returns plain rows —
8
+ # no view writes to the terminal itself, so the app can compose and repaint
9
+ # them as one frame.
10
+ module Views
11
+ module_function
12
+
13
+ TYPE_COLOURS = {
14
+ "Overview" => :magenta,
15
+ "Capability" => :cyan,
16
+ "Component" => :green,
17
+ "Constraint" => :yellow,
18
+ "Format" => :blue
19
+ }.freeze
20
+
21
+ def type_colour(type)
22
+ TYPE_COLOURS.fetch(type.to_s, :white)
23
+ end
24
+
25
+ # A bundle's standing, in the order that matters: conformance is a hard
26
+ # failure (§9), lint is advisory. Asking forces a validate and a lint, which
27
+ # Model memoizes, so a bundle is judged once however many places show it.
28
+ def health_status(model)
29
+ return :unknown if model.nil?
30
+ return :error unless model.validation.valid?
31
+ return :warn unless model.lint.warnings.empty?
32
+
33
+ :ok
34
+ end
35
+
36
+ # One vocabulary for that standing, so the name in the header, the badge in
37
+ # the footer, the row in the registry and the health tab cannot disagree.
38
+ STATUS = {
39
+ error: { fg: :red, bg: :on_red, mark: "✗" },
40
+ warn: { fg: :yellow, bg: :on_yellow, mark: "▲" },
41
+ ok: { fg: :green, bg: :on_green, mark: "✓" },
42
+ unknown: { fg: :bright_black, bg: :on_bright_black, mark: "·" }
43
+ }.freeze
44
+
45
+ def status_style(model)
46
+ STATUS.fetch(health_status(model))
47
+ end
48
+
49
+ # ── header ───────────────────────────────────────────────────────────────
50
+
51
+ def header(app, width)
52
+ model = app.model
53
+ return workspace_header(app, width) if model.nil?
54
+
55
+ health = model.validation.valid?
56
+ warnings = model.lint.warnings.length
57
+
58
+ title = Ui.line(width) do |row|
59
+ row.add(" okf ", :black, :on_cyan, :bold)
60
+ # The active bundle is reversed, not just bold: with several open, which
61
+ # one the other views are about is the single most important thing on
62
+ # screen, and it has to survive a glance after switching.
63
+ row.add(" @#{model.name} ", :black, status_style(model)[:bg], :bold)
64
+ row.add(" #{model.dir} ", :bright_black)
65
+ unless app.workspace.entries.length < 2
66
+ row.add(" · #{app.workspace.entries.length} bundles ", :cyan)
67
+ row.add("· #{app.workspace.scope.length} in scope ", :bright_black)
68
+ end
69
+ end
70
+
71
+ stats = Ui.line(width) do |row|
72
+ row.add(" #{model.concept_count} concepts", :white)
73
+ row.add(" #{model.edge_count} links", :white)
74
+ row.add(" #{model.dirs.length} dirs", :white)
75
+ row.space(2)
76
+ if health
77
+ row.add("✓ conformant", :green)
78
+ else
79
+ row.add("✗ #{model.validation.errors.length} errors", :red, :bold)
80
+ end
81
+ row.space(2)
82
+ if warnings.zero?
83
+ row.add("✓ lint clean", :green)
84
+ else
85
+ row.add("▲ #{warnings} lint warnings", :yellow)
86
+ end
87
+
88
+ # Reads are best-effort: a file the reader could not use is skipped
89
+ # rather than aborting the bundle. Say so — a silently shorter list is
90
+ # the one failure mode that reads as success.
91
+ skipped = model.bundle.unparseable.length
92
+ unless skipped.zero?
93
+ row.space(2)
94
+ row.add("⊘ #{skipped} unreadable", :red)
95
+ end
96
+ end
97
+
98
+ [ title, stats, tabs(app, width) ]
99
+ end
100
+
101
+ # Shown when no bundle could be read — an empty registry, or one whose
102
+ # directories have all gone. There is nothing to say about a bundle, so the
103
+ # header talks about the workspace instead.
104
+ def workspace_header(app, width)
105
+ title = Ui.line(width) do |row|
106
+ row.add(" okf ", :black, :on_cyan, :bold)
107
+ row.add(" no bundle open ", :bright_white, :bold)
108
+ row.add("· #{app.workspace.registry_path} ", :bright_black) if app.workspace.registry_backed?
109
+ end
110
+
111
+ stats = Ui.line(width) do |row|
112
+ if app.workspace.empty?
113
+ row.add(" the registry is empty — press a to register a bundle", :yellow)
114
+ else
115
+ row.add(" #{app.workspace.entries.length} bundles, none readable", :red)
116
+ end
117
+ end
118
+
119
+ [ title, stats, tabs(app, width) ]
120
+ end
121
+
122
+ def tabs(app, width)
123
+ Ui.line(width) do |row|
124
+ row.space
125
+ style = status_style(app.model)
126
+ attention = %i[error warn].include?(health_status(app.model))
127
+
128
+ app.class::TABS.each_with_index do |(key, label), index|
129
+ # The health tab wears the verdict. Nothing else on a browse or graph
130
+ # screen says the bundle is broken, and "go look at tab 5" is exactly
131
+ # what a user needs told before they think to ask.
132
+ flagged = key == :health && attention
133
+ text = " #{index + 1} #{label}#{" #{style[:mark]}" if flagged} "
134
+
135
+ if app.view == key
136
+ row.add(text, :black, flagged ? style[:bg] : :on_bright_white, :bold)
137
+ elsif flagged
138
+ row.add(text, style[:fg], :bold)
139
+ else
140
+ row.add(text, :bright_black)
141
+ end
142
+ row.space
143
+ end
144
+ end
145
+ end
146
+
147
+ # Keys on the left, the active bundle pinned to the right. The header names
148
+ # it too, but the footer is the line the eye is already on while working,
149
+ # and after switching bundles "which one am I in" should never need a look
150
+ # away from it.
151
+ def footer(app, width)
152
+ badge = active_badge(app)
153
+ badge_width = Ui.width(badge)
154
+
155
+ keys = Ui.line([ width - badge_width, 0 ].max) do |row|
156
+ row.space
157
+ app.status_hints.each do |key, meaning|
158
+ row.add(" #{key} ", :black, :on_bright_black)
159
+ row.add(" #{meaning} ", :bright_black)
160
+ end
161
+ end
162
+
163
+ keys + badge
164
+ end
165
+
166
+ def active_badge(app)
167
+ model = app.model
168
+ return "" if model.nil? || app.workspace.entries.length < 2
169
+
170
+ Ui.line(Ui.width(" bundle @#{model.name} ")) do |row|
171
+ row.add(" bundle ", :bright_black)
172
+ row.add("@#{model.name} ", :black, status_style(model)[:bg], :bold)
173
+ end
174
+ end
175
+
176
+ # ── bundles: the workspace — switch, scope, configure ───────────────────
177
+
178
+ # Three panes: the registry's bundles and its groups stacked down the left, and
179
+ # the detail of whichever of the two has focus on the right.
180
+ #
181
+ # The groups get a box rather than a heading inside the bundle list, because a
182
+ # heading scrolls away — a registry of thirteen bundles put the groups below the
183
+ # fold on a short terminal, which is no way to show a thing you are meant to
184
+ # select. Boxed, they are always on screen, and Tab is the jump to them.
185
+ def bundles(app, width, height)
186
+ left_width = [ [ (width * 0.44).to_i, 38 ].max, width - 32 ].min
187
+ groups_height = groups_box_height(app, height)
188
+
189
+ left = Ui.box(
190
+ bundle_list(app, left_width - 2, height - groups_height - 2),
191
+ width: left_width, height: height - groups_height,
192
+ title: bundles_title(app), active: app.bundles_pane?
193
+ )
194
+
195
+ # Vertical stacking is concatenation: both boxes are already rectangles of the
196
+ # same width, which is the invariant Ui.box guarantees.
197
+ left += Ui.box(
198
+ group_list(app, left_width - 2, groups_height - 2),
199
+ width: left_width, height: groups_height,
200
+ title: groups_title(app), active: app.groups_pane?
201
+ )
202
+
203
+ right = Ui.box(
204
+ bundle_detail(app, width - left_width - 2, height - 2),
205
+ width: width - left_width, height: height,
206
+ title: detail_title(app), active: app.member_pane?
207
+ )
208
+
209
+ Ui.hjoin(left, right)
210
+ end
211
+
212
+ # Sized to the groups it holds, so a registry with none gives its rows to the
213
+ # bundles instead of framing an empty box — but never more than half the column,
214
+ # since the bundles are the longer list and the one opened by default.
215
+ def groups_box_height(app, height)
216
+ wanted = app.visible_groups.length + 2
217
+ floor = app.workspace.groups.empty? ? 3 : 4
218
+ [ [ wanted, floor ].max, [ height / 2, 3 ].max ].min
219
+ end
220
+
221
+ def groups_title(app)
222
+ count = app.workspace.groups.length
223
+ return "groups" if count == app.visible_groups.length
224
+
225
+ "groups #{app.visible_groups.length}/#{count}"
226
+ end
227
+
228
+ def detail_title(app)
229
+ return "members of @#{app.selected_group.slug}" if app.member_pane?
230
+ return "group" if app.detailing_group?
231
+
232
+ "bundle"
233
+ end
234
+
235
+ def bundles_title(app)
236
+ base = app.workspace.registry_backed? ? "registry" : "bundles (ad-hoc)"
237
+ app.filter.empty? ? base : "#{base} /#{app.filter}"
238
+ end
239
+
240
+ def bundle_list(app, width, height)
241
+ return no_bundles(app, width) if app.workspace.entries.empty?
242
+
243
+ entries = app.visible_entries
244
+ if entries.empty?
245
+ rows = [ Ui.line(width) { |r| r.add(" no bundle matches “#{app.filter}”", :yellow) } ]
246
+ # The way out, on the row under the dead end rather than in the footer
247
+ # alone: this pane is wide, and the offer is the whole reason Enter still
248
+ # does something here.
249
+ if app.filter_found_nothing?
250
+ rows << Ui.blank_line(width)
251
+ rows << Ui.line(width) do |r|
252
+ r.add(" ↵ searches every bundle for “#{app.filter}” instead", :bright_black)
253
+ end
254
+ end
255
+ return rows
256
+ end
257
+
258
+ # One pad across both left panes, so the two lists line up: a group is
259
+ # addressed by an @slug exactly as a bundle is, and staggered columns would
260
+ # suggest otherwise.
261
+ pad = registry_pad(app)
262
+ window = app.window(entries.length, height)
263
+
264
+ entries[window, height].to_a.each_with_index.map do |entry, offset|
265
+ bundle_row(app, entry, window + offset == app.cursor && app.bundles_pane?, width, pad)
266
+ end
267
+ end
268
+
269
+ def registry_pad(app)
270
+ (app.visible_entries.map { |entry| entry.slug.to_s.length } +
271
+ app.visible_groups.map { |group| group.slug.to_s.length }).max.to_i
272
+ end
273
+
274
+ def group_list(app, width, height)
275
+ groups = app.visible_groups
276
+ if groups.empty?
277
+ return [ Ui.line(width) do |r|
278
+ r.add(app.workspace.registry_backed? ? " none — c names the scoped bundles as one" : " none",
279
+ :bright_black)
280
+ end ]
281
+ end
282
+
283
+ pad = registry_pad(app)
284
+ # Its own window: a pane with its own cursor needs its own scroll, or a long
285
+ # group list would be clipped rather than scrolled.
286
+ window = app.group_window(groups.length, height)
287
+
288
+ groups[window, height].to_a.each_with_index.map do |group, offset|
289
+ group_row(app, group, window + offset == app.group_cursor, width, pad)
290
+ end
291
+ end
292
+
293
+ # A group row reads as what it is for: a named search scope. It carries no
294
+ # active dot and no default flag — a group is never the bundle the other views
295
+ # are about — and the ◉ is filled only when the scope in force *is* this
296
+ # group's set, which is the one thing a glance wants to know.
297
+ def group_row(app, group, selected, width, pad)
298
+ Ui.line(width) do |row|
299
+ # The cursor shows even when this pane has no focus, dimmed. `+` in the
300
+ # bundles pane acts on *this* row, and a key that reaches across panes needs
301
+ # a row the reader can see it pointing at — the footer used to spell the slug
302
+ # out instead, which claimed a selection nothing on screen agreed with.
303
+ focused = app.groups_pane?
304
+ row.add(selected ? "▸ " : " ", focused ? :cyan : :bright_black, *(focused ? [ :bold ] : []))
305
+ in_force = app.group_in_scope?(group)
306
+ row.add(in_force ? "◉ " : "○ ", in_force ? :cyan : :bright_black)
307
+ row.add(" ", :bright_black) # where a bundle carries its active dot
308
+ row.add("@#{group.slug}".ljust(pad + 2), group.cyclic? ? :red : :blue, :bold)
309
+
310
+ if group.cyclic?
311
+ row.add(" unresolvable — a cycle in the registry", :red)
312
+ else
313
+ row.add(group.size.to_s.rjust(4), :bright_black)
314
+ row.add(group.size == 1 ? " bundle" : " bundles", :bright_black)
315
+ end
316
+ end
317
+ end
318
+
319
+ def no_bundles(_app, width)
320
+ [
321
+ Ui.line(width) { |r| r.add(" nothing registered", :yellow) },
322
+ Ui.blank_line(width),
323
+ Ui.line(width) { |r| r.add(" press a to register a bundle directory", :bright_black) }
324
+ ]
325
+ end
326
+
327
+ def bundle_row(app, entry, selected, width, pad)
328
+ scoped = app.scoped?(entry.slug)
329
+ active = entry.slug == app.workspace.active_slug
330
+
331
+ Ui.line(width) do |row|
332
+ row.add(selected ? "▸ " : " ", :cyan, :bold)
333
+ # Two different states, so two different marks: ◉ is "a search covers
334
+ # this", ● is "this is the bundle the other views are about".
335
+ row.add(scoped ? "◉ " : "○ ", scoped ? :cyan : :bright_black)
336
+ row.add(active ? "● " : " ", :green, :bold)
337
+ # Red for a non-conformant bundle, amber for lint warnings — the point
338
+ # of the list is spotting which one needs attention.
339
+ slug_colour = entry.loaded? ? status_style(entry.model)[:fg] : :red
340
+ row.add("@#{entry.slug}".ljust(pad + 2), slug_colour, :bold)
341
+
342
+ if entry.loaded?
343
+ row.add(entry.concepts.to_s.rjust(4), :bright_black)
344
+ row.add(entry.concepts == 1 ? " concept" : " concepts", :bright_black)
345
+ else
346
+ row.add(" #{entry.error}", :red)
347
+ end
348
+
349
+ row.add(" default", :magenta) if entry.default?
350
+ end
351
+ end
352
+
353
+ def bundle_detail(app, width, height)
354
+ group = bundle_detail_subject(app)
355
+ return group_detail(app, group, width) if group
356
+
357
+ entry = app.selected_entry
358
+ return [ Ui.line(width) { |r| r.add(" nothing selected", :bright_black) } ] if entry.nil?
359
+
360
+ bundle_entry_detail(app, entry, width, height)
361
+ end
362
+
363
+ def bundle_detail_subject(app)
364
+ app.detailing_group? ? app.selected_group : nil
365
+ end
366
+
367
+ # What a group is: the members as the registry file records them, and the
368
+ # bundles they resolve to. Both, because for a nested group they differ — a
369
+ # member can itself be a group — and the resolved list is what a search would
370
+ # actually cover.
371
+ def group_detail(app, group, width)
372
+ rows = []
373
+ rows << Ui.line(width) do |r|
374
+ r.add("@#{group.slug}", group.cyclic? ? :red : :blue, :bold)
375
+ r.add(" group", :bright_black)
376
+ r.add(" in scope", :cyan, :bold) if app.group_in_scope?(group)
377
+ end
378
+ rows << Ui.blank_line(width)
379
+
380
+ if group.cyclic?
381
+ rows << Ui.line(width) { |r| r.add(" ⚠ its members form a cycle, so okf cannot resolve it", :red) }
382
+ rows << Ui.blank_line(width)
383
+ rows << Ui.line(width) { |r| r.add(" edit #{app.workspace.registry.path} to break it", :bright_black) }
384
+ return rows
385
+ end
386
+
387
+ rows << pair(width, "members", group.members.length)
388
+ rows << pair(width, "bundles", group.size)
389
+ rows << Ui.blank_line(width)
390
+
391
+ rows.concat(group_member_rows(app, group, width))
392
+ rows << Ui.blank_line(width)
393
+ rows << Ui.blank_line(width)
394
+ if app.member_pane?
395
+ rows << Ui.line(width) { |r| r.add(" - removes the member under the cursor (asks first)", :bright_black) }
396
+ rows << Ui.line(width) { |r| r.add(" Esc steps back to the groups", :bright_black) }
397
+ else
398
+ rows << Ui.line(width) { |r| r.add(" ↵ scopes the search to it · n renames · x deletes", :bright_black) }
399
+ rows << Ui.line(width) { |r| r.add(" Tab steps into the members, where - removes one", :bright_black) }
400
+ rows << Ui.line(width) do |r|
401
+ r.add(" + in the bundles pane adds the bundle under that cursor", :bright_black)
402
+ end
403
+ end
404
+ rows
405
+ end
406
+
407
+ # A member is named as the registry names it, with what it turned out to be
408
+ # beside it: a bundle's concept count, or "group" for a nested one. A member
409
+ # naming nothing registered is called out rather than dropped — that is a
410
+ # registry to fix, and a silently shorter list is how it stays unfixed.
411
+ def group_member_rows(app, group, width)
412
+ focused = app.member_pane?
413
+
414
+ group.members.each_with_index.map do |member, index|
415
+ entry = app.workspace.entry(member)
416
+ nested = app.workspace.group(member)
417
+ on = focused && index == app.member_cursor
418
+
419
+ Ui.line(width) do |r|
420
+ r.add(on ? " ▸ " : " ", :cyan, :bold)
421
+ r.add("@#{member}", entry || nested ? :bright_white : :red, *(on ? [ :bold ] : []))
422
+ if entry
423
+ r.add(" #{entry.concepts} #{entry.concepts == 1 ? "concept" : "concepts"}", :bright_black) if entry.loaded?
424
+ r.add(" #{entry.error}", :red) unless entry.loaded?
425
+ elsif nested
426
+ r.add(" group of #{nested.size}", :blue)
427
+ else
428
+ r.add(" not registered", :red)
429
+ end
430
+ end
431
+ end
432
+ end
433
+
434
+ def bundle_entry_detail(app, entry, width, _height)
435
+ rows = []
436
+ rows << Ui.line(width) do |r|
437
+ r.add("@#{entry.slug}", entry.loaded? ? status_style(entry.model)[:fg] : :red, :bold)
438
+ r.add(" active", :green, :bold) if entry.slug == app.workspace.active_slug
439
+ r.add(" default", :magenta) if entry.default?
440
+ end
441
+ rows << Ui.line(width) { |r| r.add(entry.dir.to_s, :bright_black) }
442
+ rows.concat(membership_rows(app, entry, width))
443
+ rows << Ui.blank_line(width)
444
+
445
+ unless entry.loaded?
446
+ rows << Ui.line(width) { |r| r.add(" ⚠ #{entry.error}", :red) }
447
+ rows << Ui.blank_line(width)
448
+ rows << Ui.line(width) { |r| r.add(" x removes it from the registry", :bright_black) }
449
+ return rows
450
+ end
451
+
452
+ model = entry.model
453
+ rows << pair(width, "concepts", model.concept_count)
454
+ rows << pair(width, "links", model.edge_count)
455
+ rows << pair(width, "dirs", model.dirs.length)
456
+ rows << pair(width, "orphans", model.orphan_ids.length, model.orphan_ids.empty? ? :green : :yellow)
457
+ rows << Ui.blank_line(width)
458
+
459
+ rows << Ui.line(width) do |r|
460
+ r.add(" conformance ", :bright_black)
461
+ if model.validation.valid?
462
+ version = model.okf_version
463
+ r.add(version.to_s.empty? ? "✓ conformant" : "✓ legal OKF v#{version}", :green)
464
+ else
465
+ r.add("✗ #{model.validation.errors.length} errors", :red, :bold)
466
+ end
467
+ end
468
+ rows << Ui.line(width) do |r|
469
+ r.add(" curation ", :bright_black)
470
+ warnings = model.lint.warnings.length
471
+ if warnings.zero?
472
+ r.add("✓ lint clean", :green)
473
+ else
474
+ r.add("▲ #{warnings} warnings", :yellow)
475
+ end
476
+ end
477
+
478
+ skipped = model.bundle.unparseable.length
479
+ unless skipped.zero?
480
+ rows << Ui.line(width) do |r|
481
+ r.add(" files ", :bright_black)
482
+ r.add("⊘ #{skipped} unreadable", :red)
483
+ end
484
+ end
485
+
486
+ rows << Ui.blank_line(width)
487
+ rows << Ui.line(width) { |r| r.add(" ↵ open it · space toggles search scope", :bright_black) }
488
+
489
+ # Only the offer, and only when there is one to make: a bundle already in the
490
+ # group says so on its own row and in the `in @…` line above.
491
+ group = app.selected_group
492
+ if group && !app.member_group(entry.slug)
493
+ rows << Ui.line(width) { |r| r.add(" + puts it in @#{group.slug}", :bright_black) }
494
+ end
495
+
496
+ rows
497
+ end
498
+
499
+ # Every group that names this bundle, not only the one selected below: the row
500
+ # can show one membership, and a bundle can have several. This is the line that
501
+ # answers "did that + land" when the group being edited is scrolled out of the
502
+ # groups pane.
503
+ def membership_rows(app, entry, width)
504
+ groups = app.workspace.groups.select { |group| group.members.include?(entry.slug) }
505
+ return [] if groups.empty?
506
+
507
+ [ Ui.line(width) do |r|
508
+ r.add("in ", :bright_black)
509
+ groups.each { |group| r.add("@#{group.slug} ", :blue) }
510
+ end ]
511
+ end
512
+
513
+ def pair(width, label, value, colour = :bright_white)
514
+ Ui.line(width) do |r|
515
+ r.add(" #{label.ljust(12)} ", :bright_black)
516
+ r.add(value.to_s, colour, :bold)
517
+ end
518
+ end
519
+
520
+ # ── browse: concept list + detail ────────────────────────────────────────
521
+
522
+ def browse(app, width, height)
523
+ left_width = [ [ (width * 0.38).to_i, 34 ].max, width - 30 ].min
524
+ right_width = width - left_width
525
+
526
+ left = Ui.box(
527
+ concept_list(app, left_width - 2, height - 2),
528
+ width: left_width, height: height,
529
+ title: browse_list_title(app), active: app.pane == :list
530
+ )
531
+
532
+ right = Ui.box(
533
+ concept_detail(app, right_width - 2, height - 2),
534
+ width: right_width, height: height,
535
+ title: "detail", active: app.pane == :detail
536
+ )
537
+
538
+ Ui.hjoin(left, right)
539
+ end
540
+
541
+ def browse_list_title(app)
542
+ app.filter.empty? ? "concepts" : "concepts /#{app.filter}"
543
+ end
544
+
545
+ # The list, grouped under directory headings, windowed around the cursor.
546
+ def concept_list(app, width, height)
547
+ entries = app.list_entries
548
+ return empty_list(app, width) if entries.empty?
549
+
550
+ window = app.window(entries.length, height)
551
+
552
+ entries[window, height].to_a.each_with_index.map do |entry, offset|
553
+ index = window + offset
554
+ if entry[:kind] == :dir
555
+ Ui.line(width) do |row|
556
+ row.space
557
+ row.add(entry[:label].upcase, :bright_black, :bold)
558
+ row.add(" (#{entry[:count]})", :bright_black)
559
+ end
560
+ elsif entry[:kind] == :reserved
561
+ reserved_row(entry, index == app.cursor, width)
562
+ else
563
+ concept_row(app, entry[:row], index == app.cursor, width)
564
+ end
565
+ end
566
+ end
567
+
568
+ # An empty list is a dead end unless it says where to go next. A filter that
569
+ # found nothing is usually not "it does not exist" but "it is not in the
570
+ # part of this bundle a filter can see", so the way out is offered here.
571
+ def empty_list(app, width)
572
+ return [ Ui.line(width) { |r| r.add(" no concepts here", :bright_black) } ] unless app.filter_found_nothing?
573
+
574
+ # Short here — the pane is narrow. The offer itself goes in the detail
575
+ # pane, which is wide and otherwise saying "nothing selected".
576
+ [ Ui.line(width) { |r| r.add(" no matches in this bundle", :yellow) } ]
577
+ end
578
+
579
+ # index.md and log.md are structure, not concepts, so they read differently
580
+ # in the list: a file name rather than a title, and no type dot.
581
+ def reserved_row(entry, selected, width)
582
+ name = File.basename(entry[:path])
583
+
584
+ Ui.line(width) do |row|
585
+ row.add(selected ? "▸ " : " ", :cyan, :bold)
586
+ row.add("▪ ", :bright_black)
587
+ row.add(name, selected ? :bright_white : :bright_black, :bold)
588
+ row.add(name == "index.md" ? " the way in" : " the log", :bright_black)
589
+ end
590
+ end
591
+
592
+ def concept_row(app, item, selected, width)
593
+ Ui.line(width) do |row|
594
+ row.add(selected ? "▸ " : " ", :cyan, :bold)
595
+ row.add("● ", type_colour(item[:type]))
596
+
597
+ label = item[:title].to_s.empty? ? item[:id] : item[:title]
598
+ if selected
599
+ row.add(label, :bright_white, :bold)
600
+ else
601
+ row.add(label, :white)
602
+ end
603
+
604
+ findings = app.model.findings_for(item)
605
+ row.add(" ▲#{findings.length}", :yellow) unless findings.empty?
606
+ end
607
+ end
608
+
609
+ # The right-hand pane: frontmatter, then the body rendered as markdown.
610
+ def concept_detail(app, width, height)
611
+ return escalation_panel(app, width) if app.filter_found_nothing?
612
+
613
+ entry = app.selected_browse_entry
614
+ return reserved_detail(app, entry, width, height) if entry && entry[:kind] == :reserved
615
+
616
+ item = app.selected_row
617
+ return [ Ui.line(width) { |r| r.add(" nothing selected", :bright_black) } ] if item.nil?
618
+
619
+ rows = []
620
+ rows << Ui.line(width) { |r| r.add(item[:title].to_s, :bright_white, :bold) }
621
+ rows << Ui.line(width) do |r|
622
+ r.add(item[:type].to_s, type_colour(item[:type]), :bold)
623
+ r.add(" #{item[:path]}", :bright_black)
624
+ end
625
+
626
+ unless item[:description].to_s.empty?
627
+ rows << Ui.blank_line(width)
628
+ wrap(item[:description], width - 2).each do |text|
629
+ rows << Ui.line(width) { |r| r.add(" #{text}", :white) }
630
+ end
631
+ end
632
+
633
+ rows << Ui.blank_line(width)
634
+ rows << Ui.line(width) do |r|
635
+ r.add(" links ", :bright_black)
636
+ r.add("→#{item[:links_out]} ", :green)
637
+ r.add("←#{item[:links_in]}", :blue)
638
+ unless Array(item[:tags]).empty?
639
+ r.add(" tags ", :bright_black)
640
+ r.add(Array(item[:tags]).join(" · "), :cyan)
641
+ end
642
+ end
643
+
644
+ rows.concat(provenance_rows(item, width))
645
+
646
+ findings = app.model.findings_for(item)
647
+ unless findings.empty?
648
+ rows << Ui.blank_line(width)
649
+ findings.first(4).each do |finding|
650
+ rows << Ui.line(width) do |r|
651
+ r.add(" ▲ ", :yellow)
652
+ r.add("#{finding[:check]} ", :yellow, :bold)
653
+ r.add(finding[:message].to_s, :white)
654
+ end
655
+ end
656
+ end
657
+
658
+ rows << Ui.blank_line(width)
659
+ rows << Ui.line(width) { |r| r.add("─" * width, :bright_black) }
660
+
661
+ rows.concat(document_block(app, width, height - rows.length) { app.rendered_body(item, width - 2) })
662
+ rows
663
+ end
664
+
665
+ # §5's provenance families, on the rows that declared them.
666
+ #
667
+ # Everything here is conditional on the concept having *said* something, which
668
+ # is the v0.1 half of okf's own rule: v0.2 only added optional keys, so a
669
+ # bundle that adopted none of them must not read as deficient — it reads as it
670
+ # always did, with no rows at all. §13.1 does the rest: a v0.1 `timestamp:`
671
+ # arrives as `generated_at` with no actor invented for it, so the "updated"
672
+ # line an unmigrated bundle has always shown still shows.
673
+ #
674
+ # The trust tier is the one that needs a rule rather than a presence check,
675
+ # because §5.3 *derives* `unverified` for every concept that verified nothing.
676
+ # Printing that would paint a provenance verdict onto a document that never
677
+ # made one. `Model.shows_trust?` is okf's predicate, shared with its server and
678
+ # its graph page, and the facet in the graph view gates on the same call.
679
+ def provenance_rows(item, width)
680
+ rows = []
681
+
682
+ unless item[:generated_at].to_s.empty?
683
+ rows << Ui.line(width) do |r|
684
+ r.add(" updated ", :bright_black)
685
+ r.add(item[:generated_at].to_s, :white)
686
+ # No actor means §13.1 lifted this from a v0.1 `timestamp`, which
687
+ # recorded none. Naming one would be the false provenance §5 prevents.
688
+ unless item[:generated_by].to_s.empty?
689
+ r.add(" by ", :bright_black)
690
+ r.add(item[:generated_by].to_s, :white)
691
+ end
692
+ end
693
+ end
694
+
695
+ marks = []
696
+ marks << [ item[:trust].to_s, trust_colour(item[:trust]) ] if Model.shows_trust?(item)
697
+ # The producer's own spelling, and only when it is not the §5.4 default:
698
+ # `stable` is what an undeclared status already means, so a row saying it
699
+ # carries no information the absence did not.
700
+ unless item[:status].to_s.empty? || OKF::Concept.effective_status(item[:status]) == "stable"
701
+ marks << [ item[:status].to_s, :yellow ]
702
+ end
703
+ marks << [ "expires #{item[:stale_after]}", :bright_black ] unless item[:stale_after].to_s.empty?
704
+ marks << [ "#{item[:sources]} sources", :bright_black ] if item[:sources].to_i.positive?
705
+
706
+ unless marks.empty?
707
+ rows << Ui.line(width) do |r|
708
+ r.add(" ", :bright_black)
709
+ marks.each_with_index do |(text, colour), index|
710
+ r.add(" ", :bright_black) if index.positive?
711
+ r.add(text, colour)
712
+ end
713
+ end
714
+ end
715
+
716
+ rows
717
+ end
718
+
719
+ # §5.3's three tiers, warm for the one a human signed off. Unverified stays
720
+ # neutral: where it is shown at all the concept declared §5 and simply has no
721
+ # verification yet, which is a state rather than a fault.
722
+ def trust_colour(trust)
723
+ case trust.to_s
724
+ when "human-reviewed" then :green
725
+ when "machine-confirmed" then :cyan
726
+ else :bright_black
727
+ end
728
+ end
729
+
730
+ # A filter that found nothing is usually not "it does not exist" but "it is
731
+ # not in the part of this bundle a filter can see" — the filter reads
732
+ # metadata only, and only this bundle. So the dead end offers the way on.
733
+ def escalation_panel(app, width)
734
+ rows = []
735
+ rows << Ui.line(width) { |r| r.add("nothing matched “#{app.filter}” here", :yellow, :bold) }
736
+ rows << Ui.blank_line(width)
737
+
738
+ wrap("The filter reads titles, ids, types and tags, in this bundle only.", width - 4).each do |text|
739
+ rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
740
+ end
741
+
742
+ rows << Ui.blank_line(width)
743
+ rows << Ui.line(width) do |r|
744
+ r.add(" ↵ ", :black, :on_cyan, :bold)
745
+ r.add(" #{app.escalation_offer}", :cyan, :bold)
746
+ end
747
+ rows << Ui.blank_line(width)
748
+
749
+ wrap("That reads every concept body too, ranked, and merges the bundles into one result list.", width - 4).each do |text|
750
+ rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
751
+ end
752
+
753
+ rows
754
+ end
755
+
756
+ # A reserved file has no frontmatter to summarise — it is all body.
757
+ def reserved_detail(app, entry, width, height)
758
+ rows = []
759
+ rows << Ui.line(width) { |r| r.add(File.basename(entry[:path]), :bright_white, :bold) }
760
+ rows << Ui.line(width) do |r|
761
+ r.add(entry[:path], :bright_black)
762
+ r.add(File.basename(entry[:path]) == "index.md" ? " · progressive disclosure (§6)" : " · the bundle log", :bright_black)
763
+ end
764
+ rows << Ui.blank_line(width)
765
+ rows << Ui.line(width) { |r| r.add("─" * width, :bright_black) }
766
+
767
+ rows.concat(document_block(app, width, height - rows.length) { app.rendered_reserved(entry[:path], width - 2) })
768
+ rows
769
+ end
770
+
771
+ # The detail pane's document area — the body, or the link picker in its
772
+ # place. Replacing rather than overlaying keeps the header above it, and
773
+ # means Esc puts the body back exactly where it was: nothing here touches
774
+ # the detail scroll. The body is yielded rather than passed so the picker
775
+ # does not pay tty-markdown to render a page it is covering.
776
+ def document_block(app, width, visible)
777
+ return links_block(app, width, visible) if app.following?
778
+
779
+ body_block(app, yield, width, visible)
780
+ end
781
+
782
+ # Where this document points. The list comes from okf — the same extraction
783
+ # the graph builds edges with — so these rows are a value a check can assert
784
+ # on, rather than a pattern matched against rendered markdown.
785
+ def links_block(app, width, visible)
786
+ links = app.follow_links
787
+
788
+ rows = []
789
+ rows << Ui.line(width) do |r|
790
+ r.add(" links in this document ", :bright_white, :bold)
791
+ r.add("(#{links.length})", :bright_black)
792
+ end
793
+ rows << Ui.blank_line(width)
794
+
795
+ room = [ visible - rows.length, 1 ].max
796
+ body = links.each_with_index.map { |link, index| link_row(app, link, index, width) }
797
+ offset = app.follow_window(body.length, room)
798
+ rows.concat(body[offset, room].to_a)
799
+ rows
800
+ end
801
+
802
+ # A link reads as whatever is at the far end of it, not as the path it was
803
+ # written with: a concept by its title and type colour, a nested index by its
804
+ # area, and a target with nothing behind it as exactly that — not-yet-written
805
+ # knowledge is what a broken cross-link usually is.
806
+ def link_row(app, link, index, width)
807
+ selected = index == app.follow_cursor
808
+
809
+ Ui.line(width) do |r|
810
+ r.add(selected ? "▸ " : " ", :cyan, :bold)
811
+ r.add(index < 9 ? "#{index + 1} " : " ", :bright_black)
812
+
813
+ case link[:kind]
814
+ when :concept
815
+ r.add("● ", type_colour(link[:type]))
816
+ r.add(link[:label], selected ? :bright_white : :white, :bold)
817
+ r.add(" #{link[:target]}", :bright_black)
818
+ when :reserved
819
+ r.add("▪ ", :bright_black)
820
+ r.add(link[:label], selected ? :bright_white : :bright_black, :bold)
821
+ r.add(" #{link[:target]}", :bright_black)
822
+ else
823
+ r.add("○ ", :yellow)
824
+ r.add(link[:target], :yellow)
825
+ r.add(" not written yet", :bright_black)
826
+ end
827
+ end
828
+ end
829
+
830
+ # The scrolling window over a rendered document, with the find matches
831
+ # marked. The body is already coloured by tty-markdown, so these rows bypass
832
+ # Line and are squared off by fit_block on the way into the box.
833
+ def body_block(app, body, _width, visible)
834
+ matches = app.find_matches(body)
835
+ offset = app.detail_offset(body.length, visible, matches)
836
+
837
+ body[offset, visible].to_a.each_with_index.map do |text, index|
838
+ line = offset + index
839
+ next " #{text}" unless matches.include?(line)
840
+
841
+ # The mark goes in the gutter the other rows leave empty, so a hit is
842
+ # visible without touching the rendered markdown itself.
843
+ current = matches[app.find_index % [ matches.length, 1 ].max] == line
844
+ Ui.pastel.decorate(current ? "▶ " : "· ", current ? :black : :yellow, *(current ? %i[on_yellow bold] : [])) + text
845
+ end
846
+ end
847
+
848
+ def wrap(text, limit)
849
+ words = text.to_s.split(/\s+/)
850
+ lines = []
851
+ current = +""
852
+ words.each do |word|
853
+ candidate = current.empty? ? word : "#{current} #{word}"
854
+ if Ui.width(candidate) > limit
855
+ lines << current unless current.empty?
856
+ current = word.dup
857
+ else
858
+ current = candidate
859
+ end
860
+ end
861
+ lines << current unless current.empty?
862
+ lines
863
+ end
864
+
865
+ # ── search ───────────────────────────────────────────────────────────────
866
+
867
+ def search(app, outer_width, height)
868
+ width = outer_width - 2
869
+ rows = []
870
+ editing = app.editing_query?
871
+
872
+ rows << Ui.line(width) do |r|
873
+ r.add(" search ", :bright_black)
874
+ r.add(app.query.empty? && !editing ? "(no query)" : app.query, :bright_white, :bold)
875
+ # The caret is the focus indicator: it is only in the field while the
876
+ # field is where the typing goes.
877
+ r.add(editing ? "▏" : " ", :cyan, :bold)
878
+
879
+ # Until Enter, whatever is listed below answers the *previous* query.
880
+ # Saying so is the difference between "not searched yet" and a wrong
881
+ # answer sitting under a query it does not belong to.
882
+ if app.search_pending?
883
+ r.add(" ↵ to search", :black, :on_yellow, :bold)
884
+ elsif !editing
885
+ r.add(app.query.empty? ? " / to search" : " / to edit", :bright_black)
886
+ end
887
+ end
888
+ rows << Ui.line(width) do |r|
889
+ r.add(" mode ", :bright_black)
890
+ r.add(app.search_mode_label, :cyan, :bold)
891
+ r.add(" (e) ", :bright_black)
892
+ r.add(" #{app.search_mode_note}", :bright_black)
893
+ end
894
+ rows << Ui.line(width) do |r|
895
+ error = app.workspace.search_error
896
+ next r.add(" #{error}", :yellow) if error
897
+
898
+ r.add(" one corpus over every scoped bundle, so the scores compare", :bright_black)
899
+ end
900
+ rows << Ui.blank_line(width)
901
+
902
+ scope = app.workspace.scope
903
+ hits = app.search_hits
904
+
905
+ if scope.empty?
906
+ rows << Ui.line(width) { |r| r.add(" no bundles in scope — press 1, then space to pick some", :yellow) }
907
+ elsif app.searched.empty?
908
+ # Nothing has been searched yet, so there are no results to describe —
909
+ # and saying "no matches" here would blame the query for an answer that
910
+ # was never asked for.
911
+ rows << Ui.line(width) do |r|
912
+ if app.query.empty?
913
+ r.add(" press / to type a query — titles, ids, tags, types, descriptions, bodies", :bright_black)
914
+ else
915
+ r.add(" press ↵ to search for “#{app.query}”", :yellow)
916
+ end
917
+ end
918
+ elsif hits.empty?
919
+ rows << Ui.line(width) { |r| r.add(" no matches for “#{app.searched}”", :yellow) }
920
+ else
921
+ rows << Ui.line(width) do |r|
922
+ r.add(" #{count(hits.length, "match", "matches")} across #{count(scope.length, "bundle")}", :bright_black)
923
+ # While the field is being edited the list still answers the old query.
924
+ r.add(" for “#{app.searched}”", :yellow) if app.search_pending?
925
+ end
926
+ rows << Ui.blank_line(width)
927
+
928
+ visible = height - rows.length - 2
929
+ window = app.window(hits.length, visible / 3)
930
+ hits[window, [ visible / 3, 1 ].max].to_a.each_with_index do |hit, offset|
931
+ rows.concat(search_hit(app, hit, window + offset == app.cursor, width))
932
+ end
933
+ end
934
+
935
+ Ui.box(rows, width: outer_width, height: height, title: search_title(app), active: true)
936
+ end
937
+
938
+ # The scope, named the way the equivalent CLI invocation would name it.
939
+ def search_title(app)
940
+ scope = app.workspace.scope
941
+ return "search — nothing in scope" if scope.empty?
942
+ return "search" if app.workspace.entries.length < 2
943
+
944
+ label = scope.length == app.workspace.entries.length ? "@all" : scope.map { |slug| "@#{slug}" }.join(" ")
945
+ "search #{label}"
946
+ end
947
+
948
+ def count(number, singular, plural = nil)
949
+ "#{number} #{number == 1 ? singular : plural || "#{singular}s"}"
950
+ end
951
+
952
+ def search_hit(app, hit, selected, inner)
953
+ head = Ui.line(inner) do |r|
954
+ r.add(selected ? "▸ " : " ", :cyan, :bold)
955
+ # The slug only earns its space when a search can span bundles.
956
+ r.add("@#{hit[:slug]} ", :cyan) if hit[:slug] && app.workspace.entries.length > 1
957
+ r.add(hit[:title].to_s, selected ? :bright_white : :white, :bold)
958
+ r.add(" #{hit[:type]}", type_colour(hit[:type]))
959
+ r.add(" #{format("%.2f", hit[:score])}", :bright_black)
960
+ end
961
+
962
+ meta = Ui.line(inner) do |r|
963
+ r.add(" #{hit[:id]}", :bright_black)
964
+ r.add(" matched: ", :bright_black)
965
+ r.add(Array(hit[:matched]).join(", "), :magenta)
966
+ end
967
+
968
+ snippet = Ui.line(inner) do |r|
969
+ r.add(" #{hit[:snippet]}", :bright_black)
970
+ end
971
+
972
+ [ head, meta, snippet ]
973
+ end
974
+
975
+ # ── health: validate + lint ──────────────────────────────────────────────
976
+
977
+ # Two panes: what is wrong on the left, how it stands on the right.
978
+ #
979
+ # One page mixed them, and the findings are the unbounded half — a bundle with
980
+ # two hundred lint findings pushed the dir traffic and the stats off the bottom,
981
+ # which is exactly the bundle whose structure you opened this view to look at.
982
+ # The summary is bounded by construction, so given a pane of its own it can
983
+ # never be pushed away, and the two scroll apart.
984
+ #
985
+ # The right pane carries verdicts and numbers, never a path: every row in it is
986
+ # short by design, which is what lets it keep a fixed width. The findings that
987
+ # go with those verdicts are on the left, where the columns are.
988
+ HEALTH_SUMMARY_WIDTH = 56
989
+
990
+ # Below this the two panes would each be too narrow for the paths a finding is
991
+ # *about*, so the same Tab shows one at a time instead. Splitting anyway and
992
+ # letting both clip would be the layout lying about fitting.
993
+ HEALTH_SPLIT_WIDTH = 112
994
+
995
+ def health(app, outer_width, height)
996
+ return health_page(app, outer_width, height) if outer_width < HEALTH_SPLIT_WIDTH
997
+
998
+ right_width = HEALTH_SUMMARY_WIDTH
999
+ left_width = outer_width - right_width
1000
+ findings = app.pane != :detail
1001
+
1002
+ left = scrollable(app, health_findings(app.model, left_width - 2), left_width, height,
1003
+ "findings", scroll: :content, focused: findings)
1004
+ right = scrollable(app, health_summary(app.model, right_width - 2), right_width, height,
1005
+ "standing", scroll: :health, focused: !findings)
1006
+
1007
+ Ui.hjoin(left, right)
1008
+ end
1009
+
1010
+ # Narrow: one pane at a time, chosen by the same Tab that puts them side by side
1011
+ # when there is room. The title says which, since only one is on screen.
1012
+ def health_page(app, outer_width, height)
1013
+ if app.pane == :detail
1014
+ scrollable(app, health_summary(app.model, outer_width - 2), outer_width, height,
1015
+ "health · standing", scroll: :health)
1016
+ else
1017
+ scrollable(app, health_findings(app.model, outer_width - 2), outer_width, height,
1018
+ "health · findings")
1019
+ end
1020
+ end
1021
+
1022
+ # The left pane: every row that names a file. Conformance errors lead, because
1023
+ # a bundle that is not legal has a different first job than one that is merely
1024
+ # untidy.
1025
+ def health_findings(model, width)
1026
+ rows = []
1027
+
1028
+ unless model.validation.valid?
1029
+ rows << section(width, "conformance — spec §9", :red)
1030
+ model.validation.errors.first(8).each do |error|
1031
+ rows << detail_line(width, "✗", "#{error[:path]} — #{error[:message]}", :red)
1032
+ end
1033
+ rows << Ui.blank_line(width)
1034
+ end
1035
+
1036
+ model.validation.warnings.first(5).each do |warning|
1037
+ rows << detail_line(width, "▲", "#{warning[:path]} — #{warning[:message]}", :yellow)
1038
+ end
1039
+ rows << Ui.blank_line(width) unless model.validation.warnings.empty?
1040
+
1041
+ rows << section(width, "curation — lint", model.lint.warnings.empty? ? :green : :yellow)
1042
+ if model.lint.findings.empty?
1043
+ rows << detail_line(width, "✓", "no curation findings", :green)
1044
+ else
1045
+ rows.concat(lint_block(model, width))
1046
+ end
1047
+
1048
+ rows.concat(hubs_block(model, width))
1049
+ rows
1050
+ end
1051
+
1052
+ # The right pane: the verdicts, then the shape. It says how many errors there
1053
+ # are rather than listing them — the list is beside it, and a path in a column
1054
+ # this narrow would be clipped into a different path.
1055
+ def health_summary(model, width)
1056
+ valid = model.validation.valid?
1057
+ rows = [ section(width, "conformance — spec §9", valid ? :green : :red) ]
1058
+ rows <<
1059
+ if valid
1060
+ detail_line(width, "✓", conformance_line(model), :green)
1061
+ else
1062
+ detail_line(width, "✗", "#{count(model.validation.errors.length, "error")} — in the findings", :red)
1063
+ end
1064
+
1065
+ rows << Ui.blank_line(width)
1066
+ warnings = model.lint.warnings.length
1067
+ rows << section(width, "curation — lint", warnings.zero? ? :green : :yellow)
1068
+ if model.lint.findings.empty?
1069
+ rows << detail_line(width, "✓", "no curation findings", :green)
1070
+ else
1071
+ info = model.lint.findings.length - warnings
1072
+ note = [ warnings.zero? ? nil : count(warnings, "warning"),
1073
+ info.zero? ? nil : "#{info} info" ].compact.join(" · ")
1074
+ rows << detail_line(width, warnings.zero? ? "ℹ" : "▲", "#{note} — in the findings",
1075
+ warnings.zero? ? :blue : :yellow)
1076
+ end
1077
+ rows.concat(skipped_block(model, width))
1078
+
1079
+ rows.concat(posture_block(model, width))
1080
+ rows.concat(traffic_block(model, width))
1081
+ rows.concat(stats_block(model, width))
1082
+ rows
1083
+ end
1084
+
1085
+ # What the bundle declares itself to be (§12), not what this screen assumes.
1086
+ # It said "legal OKF v0.1" for a release — about every bundle equally,
1087
+ # including one that had migrated — which is the whole failure mode of reading
1088
+ # a fact off a literal instead of off okf. §12 lets a bundle declare nothing,
1089
+ # and there the honest answer names no version: `validate` backs conformance,
1090
+ # never a version claim.
1091
+ def conformance_line(model)
1092
+ version = model.okf_version
1093
+ version.to_s.empty? ? "a conformant bundle" : "a legal OKF v#{version} bundle"
1094
+ end
1095
+
1096
+ # A verdict is only as good as the checks behind it, so a check that did not
1097
+ # run is said out loud. §5.5's freshness pair is clock-gated and the pure
1098
+ # library runs neither unless handed a clock — and "✓ lint clean" over a check
1099
+ # that never ran is worse than no verdict at all. okf's own CLI confesses the
1100
+ # same thing in the same words.
1101
+ def skipped_block(model, width)
1102
+ skipped = model.skipped_checks
1103
+ return [] if skipped.empty?
1104
+
1105
+ [ detail_line(width, "·", "#{skipped.join(", ")} not run — no clock supplied", :bright_black) ]
1106
+ end
1107
+
1108
+ # The bundle's trust and status posture — the two distributions okf's own lint
1109
+ # prints on its summary line, and the two `stats_block` cannot show because it
1110
+ # keeps scalars only.
1111
+ #
1112
+ # Each is shown only where the bundle has something to say, on the same rule
1113
+ # the graph page's facets use: status needs one *declared* value, since a
1114
+ # column of `stable` is what an undeclared status already means; trust needs
1115
+ # one tier okf is willing to claim, or every v0.1 bundle would wear a row
1116
+ # reading only "unverified" about a family it never adopted.
1117
+ def posture_block(model, width)
1118
+ claims_trust = model.rows.any? { |row| Model.shows_trust?(row) }
1119
+ declared_status = model.rows.any? { |row| !row[:status].to_s.empty? }
1120
+ return [] unless claims_trust || declared_status
1121
+
1122
+ rows = [ Ui.blank_line(width), section(width, "posture — §5 trust and status", :blue) ]
1123
+ # okf's own numbers, unedited — this is the line `okf lint` prints. Only the
1124
+ # *gate* is this view's, and it is a gate on the bundle rather than on the
1125
+ # tiers: once one concept claims a tier the whole distribution is the answer,
1126
+ # including the unverified ones it is measured against.
1127
+ rows << posture_row(width, "trust", model.trust_posture) if claims_trust
1128
+ rows << posture_row(width, "status", model.status_posture) if declared_status
1129
+ rows
1130
+ end
1131
+
1132
+ # Tiers and statuses the bundle has none of are dropped. okf's lint line
1133
+ # prints all three tiers because a terminal line is as wide as it needs to be;
1134
+ # this pane holds a fixed width, and it holds it because every row in it is
1135
+ # short *by construction*. `trust unverified 31 machine-confirmed 0
1136
+ # human-reviewed 0` is not, and clipped to fit it read "human-rev…".
1137
+ def posture_row(width, label, counts)
1138
+ counts = posture_counts(counts)
1139
+
1140
+ Ui.line(width) do |r|
1141
+ r.add(" #{label} ", :bright_black)
1142
+ counts.each_with_index do |(value, count), index|
1143
+ r.add(" ", :bright_black) if index.positive?
1144
+ r.add(value.to_s, :white)
1145
+ r.add(" #{count}", :bright_white, :bold)
1146
+ end
1147
+ end
1148
+ end
1149
+
1150
+ def posture_counts(counts)
1151
+ counts.reject { |_, count| count.to_i.zero? }
1152
+ end
1153
+
1154
+ # Findings grouped by check, warnings before info. Each group lists a few
1155
+ # examples and says how many it withheld, so a long list stays readable
1156
+ # without the page pretending it showed everything.
1157
+ def lint_block(model, width)
1158
+ grouped = model.lint.findings.group_by { |finding| finding[:check] }
1159
+ ordered = grouped.sort_by do |check, list|
1160
+ [ list.first[:severity] == :warn ? 0 : 1, -list.length, check.to_s ]
1161
+ end
1162
+
1163
+ # Examples shown per check. The page scrolls, so this is about keeping one
1164
+ # noisy check from burying the others, not about fitting the screen.
1165
+ examples = 3
1166
+ rows = []
1167
+
1168
+ ordered.each do |check, list|
1169
+ warn = list.first[:severity] == :warn
1170
+ rows << Ui.line(width) do |r|
1171
+ r.add(" #{warn ? "▲" : "ℹ"} ", warn ? :yellow : :blue)
1172
+ r.add(check.to_s, :bright_white, :bold)
1173
+ r.add(" ×#{list.length}", :bright_black)
1174
+ end
1175
+
1176
+ list.first(examples).each do |finding|
1177
+ rows << Ui.line(width) do |r|
1178
+ r.add(" #{finding[:path]}", :bright_black)
1179
+ r.add(" #{finding[:message]}", :white)
1180
+ end
1181
+ end
1182
+
1183
+ hidden = list.length - examples
1184
+ rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
1185
+ end
1186
+
1187
+ rows
1188
+ end
1189
+
1190
+ # Hubs, and whether each is well homed — okf's `graph --hubs`.
1191
+ #
1192
+ # The number alone is already in the graph view; what earns a place here is the
1193
+ # *judgement*, which needs the inbound breakdown beside it. A hub drawing most
1194
+ # of its links from outside its own top-level dir is flagged, and when a single
1195
+ # foreign dir dominates, that dir is named — it is the better home the concept
1196
+ # already has.
1197
+ HUBS_SHOWN = 6
1198
+
1199
+ def hubs_block(model, width)
1200
+ hubs = model.hubs
1201
+ rows = [ Ui.blank_line(width), section(width, "hubs — where their inbound links come from", :blue) ]
1202
+
1203
+ if hubs.empty?
1204
+ rows << detail_line(width, "·", "nothing is linked to yet", :bright_black)
1205
+ return rows
1206
+ end
1207
+
1208
+ hubs.first(HUBS_SHOWN).each { |hub| rows << hub_row(hub, width) }
1209
+ hidden = hubs.length - HUBS_SHOWN
1210
+ rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
1211
+ rows
1212
+ end
1213
+
1214
+ def hub_row(hub, width)
1215
+ home = hub[:top_dir].to_s
1216
+ foreign = hub[:by_top_dir].reject { |dir, _| dir.to_s == home }
1217
+ foreign_total = foreign.values.reduce(0, :+)
1218
+ away = foreign_total * 2 > hub[:inbound]
1219
+
1220
+ Ui.line(width) do |r|
1221
+ r.add(away ? " ▲ " : " · ", away ? :yellow : :bright_black)
1222
+ r.add(hub[:id].to_s, :bright_white, :bold)
1223
+ r.add(" ←#{hub[:inbound]}", :blue)
1224
+ r.add(" in #{home}", :bright_black)
1225
+ # Named only when one foreign dir carries the majority on its own: that is
1226
+ # the case where okf says the hub has already named its better home. A
1227
+ # scattered inbound majority is a different finding, and saying "move it
1228
+ # to X" on the strength of a plurality would be the wrong advice.
1229
+ top_dir, top_count = foreign.max_by { |_, count| count }
1230
+ if away && top_count && top_count * 2 > hub[:inbound]
1231
+ r.add(" → mostly from #{top_dir}", :yellow)
1232
+ elsif away
1233
+ r.add(" → #{foreign_total} of #{hub[:inbound]} from elsewhere", :yellow)
1234
+ end
1235
+ end
1236
+ end
1237
+
1238
+ # Cohesion versus coupling, one row per directory — okf's `graph --traffic`.
1239
+ #
1240
+ # This is the measurement okf added because the refine playbook's directory
1241
+ # judgements ("does this directory prune? a concern, or a container?") had
1242
+ # nothing at their own grain: `--hubs` measures concepts. Near-zero cohesion
1243
+ # under heavy inbound is a shared vocabulary doing its job; heavy outbound with
1244
+ # nothing coming back is a projection wearing a directory. Which of those it is
1245
+ # remains the reader's call — the row supplies the evidence, not the verdict.
1246
+ #
1247
+ # Model#dir_traffic has already sorted by cohesion ascending, so the directories
1248
+ # with a case to answer are the ones on screen first.
1249
+ TRAFFIC_SHOWN = 8
1250
+
1251
+ def traffic_block(model, width)
1252
+ traffic = model.dir_traffic
1253
+ rows = [ Ui.blank_line(width), section(width, "dir traffic — internal share of each dir's links", :yellow) ]
1254
+
1255
+ # One directory is the whole bundle: there is no traffic *between*
1256
+ # directories to weigh, so every row would be a tautology.
1257
+ if traffic.length < 2
1258
+ rows << detail_line(width, "·", "one directory — nothing to weigh it against", :bright_black)
1259
+ return rows
1260
+ end
1261
+
1262
+ rows << Ui.line(width) do |r|
1263
+ r.add(" #{"dir".ljust(traffic_pad(traffic))}", :bright_black)
1264
+ r.add(" internal out in cohesion", :bright_black)
1265
+ end
1266
+ traffic.first(TRAFFIC_SHOWN).each { |row| rows << traffic_row(row, width, traffic_pad(traffic)) }
1267
+ hidden = traffic.length - TRAFFIC_SHOWN
1268
+ rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
1269
+ rows.concat(arc_rows(model, width))
1270
+ rows
1271
+ end
1272
+
1273
+ # Which directories actually talk to which. The table above says how much of a
1274
+ # directory's traffic stays home; these say where the rest of it goes, which is
1275
+ # the other half of `graph --traffic` and the half that names a pair.
1276
+ ARCS_SHOWN = 6
1277
+
1278
+ def arc_rows(model, width)
1279
+ arcs, cut, total = model.dir_arcs
1280
+ return [] if arcs.empty?
1281
+
1282
+ rows = [ Ui.blank_line(width) ]
1283
+ rows << Ui.line(width) do |r|
1284
+ r.add(" #{arcs.length} of #{total} arcs", :bright_black)
1285
+ # Say the cut, because the list is narrowed and a silently shortened list
1286
+ # reads as a complete one. It is fitted to this bundle by okf, not fixed.
1287
+ r.add(" at weight #{cut} or more", :bright_black)
1288
+ end
1289
+
1290
+ pad = arcs.first(ARCS_SHOWN).map { |arc| Ui.width(traffic_label(dir: arc[:source])) }.max.to_i
1291
+ arcs.first(ARCS_SHOWN).each do |arc|
1292
+ rows << Ui.line(width) do |r|
1293
+ r.add(" #{traffic_label(dir: arc[:source]).ljust(pad)}", :white)
1294
+ r.add(" → ", :bright_black)
1295
+ r.add(traffic_label(dir: arc[:target]), :white)
1296
+ r.add(" ×#{arc[:weight]}", :bright_black)
1297
+ end
1298
+ end
1299
+
1300
+ hidden = arcs.length - ARCS_SHOWN
1301
+ rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
1302
+ rows
1303
+ end
1304
+
1305
+ def traffic_pad(traffic)
1306
+ traffic.map { |row| Ui.width(traffic_label(row)) }.max.to_i
1307
+ end
1308
+
1309
+ # `(root)` for a reader, as everywhere else — `.` is the stored spelling.
1310
+ def traffic_label(row)
1311
+ row[:dir] == "." ? "(root)" : row[:dir].to_s
1312
+ end
1313
+
1314
+ def traffic_row(row, width, pad)
1315
+ cohesion = row[:cohesion]
1316
+
1317
+ Ui.line(width) do |r|
1318
+ r.add(" #{traffic_label(row).ljust(pad)}", :white)
1319
+ r.add(" #{row[:internal].to_s.rjust(8)}", :bright_black)
1320
+ r.add(" #{row[:out].to_s.rjust(4)}", :bright_black)
1321
+ r.add(" #{row[:in].to_s.rjust(4)}", :bright_black)
1322
+ # A dash, not 0% — a directory with no traffic at all has not earned a
1323
+ # number, which is the distinction okf's own view is careful to draw.
1324
+ if cohesion.nil?
1325
+ r.add(" —", :bright_black)
1326
+ else
1327
+ r.add(" #{"#{cohesion}%".rjust(4)}", cohesion_colour(cohesion))
1328
+ end
1329
+ end
1330
+ end
1331
+
1332
+ # Low cohesion is the thing worth looking at, so it is the thing that is
1333
+ # coloured. Not a verdict — okf is explicit that near-zero cohesion can be a
1334
+ # shared vocabulary doing exactly its job — which is why this is a shade of
1335
+ # attention rather than the ▲ the lint findings wear.
1336
+ def cohesion_colour(cohesion)
1337
+ return :yellow if cohesion < 25
1338
+ return :white if cohesion < 60
1339
+
1340
+ :green
1341
+ end
1342
+
1343
+ def stats_block(model, width)
1344
+ # Scalars only. The linter also reports structured stats (`hubs`, `tags`)
1345
+ # whose #to_s is a Ruby literal — the graph view already draws those
1346
+ # properly, so printing them here as inspect output would be noise.
1347
+ scalars = model.lint.stats.reject { |_, value| value.is_a?(Enumerable) }
1348
+
1349
+ rows = [ Ui.blank_line(width), section(width, "stats", :cyan) ]
1350
+ scalars.each_slice(4) do |slice|
1351
+ rows << Ui.line(width) do |r|
1352
+ slice.each do |key, value|
1353
+ r.add(" #{key} ", :bright_black)
1354
+ r.add(value.to_s, :bright_white, :bold)
1355
+ end
1356
+ end
1357
+ end
1358
+ rows
1359
+ end
1360
+
1361
+ # Draw `rows` as a box showing only the slice the scroll offset selects, and
1362
+ # say so in the title when there is more than fits. A pane that silently
1363
+ # shows the first N rows of a longer list reads as a complete one.
1364
+ def scrollable(app, rows, outer_width, height, title, offset: nil, scroll: :content, focused: true)
1365
+ inner_height = height - 2
1366
+ # `/` looks through the pane with focus. Matching both halves of a split view
1367
+ # would put the find cursor on a page the keys are not moving.
1368
+ live = offset.nil? && focused
1369
+ matches = live ? app.find_matches(rows) : []
1370
+ app.remember_page(rows) if live
1371
+ # A shared offset is passed in; otherwise the page owns its own, and a pending
1372
+ # find jump moves it.
1373
+ offset ||= app.content_offset(rows.length, inner_height, matches, scroll)
1374
+
1375
+ # Clamp to this pane's own end. A shared offset (the graph view's two
1376
+ # panes) can otherwise run past the shorter one and blank it.
1377
+ offset = [ [ offset, [ rows.length - inner_height, 0 ].max ].min, 0 ].max
1378
+ visible = (rows[offset, inner_height] || []).each_with_index.map do |row, index|
1379
+ line = offset + index
1380
+ next row unless matches.include?(line)
1381
+
1382
+ # Marked in the gutter rather than restyled: these rows are already
1383
+ # coloured. The marker costs a column, so one of the rows own trailing
1384
+ # pad spaces pays for it — clipping the tail instead would ellipsise a
1385
+ # line that actually fits.
1386
+ current = matches[app.find_index % [ matches.length, 1 ].max] == line
1387
+ mark = Ui.pastel.decorate(current ? "▶" : "·", current ? :black : :yellow, *(current ? %i[on_yellow bold] : []))
1388
+ mark + row.sub(/ \z/, "")
1389
+ end
1390
+
1391
+ title = "#{title} · “#{app.find}”" unless app.find.to_s.empty? || matches.empty?
1392
+
1393
+ label =
1394
+ if rows.length > inner_height
1395
+ last = [ offset + inner_height, rows.length ].min
1396
+ "#{title} #{offset + 1}-#{last}/#{rows.length}"
1397
+ else
1398
+ title
1399
+ end
1400
+
1401
+ Ui.box(visible, width: outer_width, height: height, title: label, active: focused)
1402
+ end
1403
+
1404
+ def section(width, label, colour)
1405
+ Ui.line(width) do |r|
1406
+ r.add(" ▌", colour, :bold)
1407
+ r.add(" #{label} ", :bright_white, :bold)
1408
+ end
1409
+ end
1410
+
1411
+ def detail_line(width, glyph, text, colour)
1412
+ Ui.line(width) do |r|
1413
+ r.add(" #{glyph} ", colour)
1414
+ r.add(text, :white)
1415
+ end
1416
+ end
1417
+
1418
+ # ── graph: shape of the knowledge graph ──────────────────────────────────
1419
+
1420
+ def graph(app, width, height)
1421
+ half = width / 2
1422
+
1423
+ left = graph_pane(app, app.graph_facet_entries, half, height, "distribution", :list)
1424
+ right = graph_pane(app, app.graph_concept_entries, width - half, height, "connectivity", :detail)
1425
+
1426
+ Ui.hjoin(left, right)
1427
+ end
1428
+
1429
+ # One pane of the graph: its rows, windowed around the cursor when it is the
1430
+ # focused one. Only the focused pane carries a cursor, so the two lists never
1431
+ # both look selected.
1432
+ def graph_pane(app, entries, width, height, title, pane)
1433
+ focused = app.pane == pane
1434
+ inner_height = height - 2
1435
+ inner_width = width - 2
1436
+
1437
+ window = focused ? app.window(entries.length, inner_height) : 0
1438
+ rows = entries[window, inner_height].to_a.each_with_index.map do |entry, offset|
1439
+ graph_row(app, entry, inner_width, focused && window + offset == app.cursor)
1440
+ end
1441
+
1442
+ Ui.box(rows, width: width, height: height, title: graph_title(app, title, entries, window, inner_height), active: focused)
1443
+ end
1444
+
1445
+ def graph_title(app, title, entries, window, inner_height)
1446
+ title = "#{title} /#{app.filter}" unless app.filter.empty?
1447
+ title = "#{title} · #{app.graph_facet[:value]}" if app.graph_facet
1448
+ return title if entries.length <= inner_height
1449
+
1450
+ "#{title} #{window + 1}-#{[ window + inner_height, entries.length ].min}/#{entries.length}"
1451
+ end
1452
+
1453
+ # A row is a heading, a blank, a note, or something selectable — a facet to
1454
+ # narrow by or a concept to go read.
1455
+ def graph_row(app, entry, width, selected)
1456
+ case entry[:kind]
1457
+ when :blank then Ui.blank_line(width)
1458
+ when :heading then section(width, entry[:label], entry[:colour])
1459
+ when :note then detail_line(width, "✓", entry[:label], entry[:colour])
1460
+ when :facet then facet_row(app, entry, width, selected)
1461
+ else concept_bar(entry, width, selected)
1462
+ end
1463
+ end
1464
+
1465
+ FACET_COLOURS = { type: :cyan, tag: :magenta, dir: :yellow }.freeze
1466
+
1467
+ # `.` is how okf stores the bundle root and `(root)` is what it calls it for a
1468
+ # reader — the same pair `okf dirs` prints. The stored spelling stays in
1469
+ # `entry[:value]`, which is what the facet matches on.
1470
+ def facet_label(entry)
1471
+ value = entry[:value].to_s
1472
+ entry[:field] == :dir && value == "." ? "(root)" : value
1473
+ end
1474
+
1475
+ def facet_row(app, entry, width, selected)
1476
+ on = app.facet_active?(entry[:field], entry[:value])
1477
+ colour = FACET_COLOURS.fetch(entry[:field], :magenta)
1478
+
1479
+ bar_row(width, selected,
1480
+ label: facet_label(entry),
1481
+ count: entry[:count],
1482
+ peak: entry[:peak],
1483
+ pad: entry[:pad],
1484
+ colour: colour,
1485
+ mark: on ? "◉ " : " ",
1486
+ label_colour: if on
1487
+ colour
1488
+ else
1489
+ (selected ? :bright_white : :white)
1490
+ end)
1491
+ end
1492
+
1493
+ def concept_bar(entry, width, selected)
1494
+ bar_row(width, selected,
1495
+ label: entry[:id].to_s,
1496
+ count: entry[:count],
1497
+ peak: entry[:peak],
1498
+ pad: entry[:pad],
1499
+ colour: entry[:colour] || :blue,
1500
+ mark: "",
1501
+ label_colour: selected ? :bright_white : :white)
1502
+ end
1503
+
1504
+ # A selectable row that still draws its bar. Making the graph navigable is
1505
+ # no reason to stop it being a graph — the bar is what makes a distribution
1506
+ # readable at a glance, and the cursor rides in front of it.
1507
+ def bar_row(width, selected, label:, count:, peak:, pad:, colour:, mark:, label_colour:)
1508
+ Ui.line(width) do |r|
1509
+ r.add(selected ? "▸ " : " ", :cyan, :bold)
1510
+ r.add(mark, colour, :bold) unless mark.empty?
1511
+
1512
+ r.add(label.ljust(pad.to_i), label_colour, :bold)
1513
+ r.add(" #{count.to_s.rjust(3)} ", :bright_black)
1514
+
1515
+ # Whatever is left after the label and the count belongs to the bar.
1516
+ room = [ width - 4 - mark.length - pad.to_i - 5, 2 ].max
1517
+ top = [ peak.to_i, 1 ].max
1518
+ blocks = count.to_i.zero? ? 0 : [ (count.to_f / top * room).round, 1 ].max
1519
+ r.add("█" * blocks, colour)
1520
+ end
1521
+ end
1522
+
1523
+ # The filter narrows every list on the page by label — a type, a tag, or a
1524
+ # concept id. Counts are the ones already computed, so a filtered bar still
1525
+ # says how many there are in the bundle, not how many survived the filter.
1526
+ def narrow_rows(rows, filter)
1527
+ return rows if filter.to_s.empty?
1528
+
1529
+ needle = filter.downcase
1530
+ rows.select { |row| row[:id].to_s.downcase.include?(needle) }
1531
+ end
1532
+
1533
+ def narrow(pairs, filter)
1534
+ return pairs if filter.to_s.empty?
1535
+
1536
+ needle = filter.downcase
1537
+ pairs.select { |label, _| label.to_s.downcase.include?(needle) }
1538
+ end
1539
+
1540
+ # ── help ─────────────────────────────────────────────────────────────────
1541
+
1542
+ KEYS = [
1543
+ [ "Navigation", [
1544
+ [ "j / ↓", "next item" ],
1545
+ [ "k / ↑", "previous item" ],
1546
+ [ "g / G", "first / last item" ],
1547
+ [ "Ctrl-d / Ctrl-u", "half page down / up" ]
1548
+ ] ],
1549
+ [ "Views", [
1550
+ [ "1 … 6", "bundles · browse · search · graph · health · help" ],
1551
+ [ "Tab", "switch pane — browse's body, a group's members, the graph's lists," ],
1552
+ [ "", "health's findings and its standing" ],
1553
+ [ "J / K", "scroll the concept body" ]
1554
+ ] ],
1555
+ [ "Bundles (view 1)", [
1556
+ [ "Enter", "open that bundle — browse/health/graph follow it" ],
1557
+ [ "Enter", "on a group: scope the search to exactly its bundles" ],
1558
+ [ "space", "put it in, or out of, the search scope" ],
1559
+ [ "A / N", "scope all bundles / none — N is how you start a fresh selection" ],
1560
+ [ "G", "jump to the end of the list, where the groups are" ],
1561
+ [ "d", "make it the registry default" ],
1562
+ [ "a", "register a directory — the one bundles key the footer leaves to here" ],
1563
+ [ "n", "rename its slug — or a group's" ],
1564
+ [ "x", "remove it from the registry (asks first) — or delete a group" ],
1565
+ [ "c", "name the bundles now in scope as a group" ]
1566
+ ] ],
1567
+ [ "Groups (view 1)", [
1568
+ [ "Tab", "cycle the three panes: bundles → groups → members" ],
1569
+ [ "Esc", "step back out, one pane at a time" ],
1570
+ [ "Enter", "on a group: make a search cover exactly its bundles" ],
1571
+ [ "+", "in the bundles pane: the bundle under the cursor joins the group" ],
1572
+ [ "", "selected below — the detail pane lists what a bundle is in" ],
1573
+ [ "-", "in the members: remove the one under the cursor (asks first)" ],
1574
+ [ "", "removing the last member deletes the group, and the question says so" ],
1575
+ [ "n / x", "rename or delete a group; okf cascades through every member list" ],
1576
+ [ "c", "name the bundles now in scope as a new group" ]
1577
+ ] ],
1578
+ [ "Graph (view 4)", [
1579
+ [ "↑ ↓", "move over the facets, or the concepts" ],
1580
+ [ "Tab", "switch between the two lists" ],
1581
+ [ "Enter", "on a type, tag or dir: narrow the whole graph by it" ],
1582
+ [ "", "a dir reaches everything beneath it, as okf's --dir does" ],
1583
+ [ "Enter", "on a concept: open it in browse" ],
1584
+ [ "Esc", "clear the facet" ]
1585
+ ] ],
1586
+ [ "Following links (view 2)", [
1587
+ [ "f", "list what this document links to — a concept body, an index, the log" ],
1588
+ [ "1 … 9", "follow that link straight away" ],
1589
+ [ "Enter", "follow the one under the cursor" ],
1590
+ [ "Esc", "put the body back, where you left it" ],
1591
+ [ "Backspace", "back to wherever you jumped from — search hits and the graph too" ]
1592
+ ] ],
1593
+ [ "Finding things", [
1594
+ [ "/", "look through what has focus — the list, or the open document" ],
1595
+ [ "n / N", "next / previous match, while reading a body" ],
1596
+ [ "Enter", "when a filter matches nothing: search every bundle for it" ],
1597
+ [ "", "the registry filter offers the same escalation, from view 1" ],
1598
+ [ "s", "jump to search — it covers every scoped bundle" ],
1599
+ [ "e", "how the query is asked: fuzzy · text · regexp (Esc out of the field first)" ],
1600
+ [ "", "fuzzy ranks and forgives typos; text is raw substring, so it finds" ],
1601
+ [ "", "$OKF_HOME and `minifts`, which the index tokenizer splits apart" ],
1602
+ [ "Enter", "search; press it again to open the selected hit" ],
1603
+ [ "↑ ↓", "pick a hit — works while typing or after Esc" ],
1604
+ [ "Esc", "stop editing and stay put; drops an unsearched query" ],
1605
+ [ "/ or i", "start or resume editing the query" ],
1606
+ [ "Ctrl-u", "clear the query while editing" ]
1607
+ ] ],
1608
+ [ "Session", [
1609
+ [ "r", "reload every bundle from disk" ],
1610
+ [ "q q", "quit — the second press confirms, so a stray q costs nothing" ]
1611
+ ] ]
1612
+ ].freeze
1613
+
1614
+ def help(app, outer_width, height)
1615
+ width = outer_width - 2
1616
+ rows = []
1617
+ rows << Ui.blank_line(width)
1618
+ KEYS.each do |group, bindings|
1619
+ rows << section(width, group, :cyan)
1620
+ bindings.each do |key, meaning|
1621
+ rows << Ui.line(width) do |r|
1622
+ r.add(" #{key.rjust(16)}", :bright_white, :bold)
1623
+ r.add(" #{meaning}", :white)
1624
+ end
1625
+ end
1626
+ rows << Ui.blank_line(width)
1627
+ end
1628
+
1629
+ rows << section(width, "about", :magenta)
1630
+ [
1631
+ "A terminal UI over the okf gem, built with the TTY toolkit.",
1632
+ "It reads bundles through OKF::Bundle::Reader and derives every",
1633
+ "answer from the pure core — the same catalog, graph, validator,",
1634
+ "linter and MiniFTS search the CLI and the graph server use.",
1635
+ "",
1636
+ "A search spans every bundle in scope through one shared index,",
1637
+ "which is what makes the scores comparable between them — the",
1638
+ "same thing `okf search @all` does.",
1639
+ "",
1640
+ "Nothing here is authored twice: the TUI is one more shell."
1641
+ ].each do |text|
1642
+ rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
1643
+ end
1644
+
1645
+ scrollable(app, rows, outer_width, height, "help")
1646
+ end
1647
+ end
1648
+ end