detached eef2c8e

feat(client): expose the picker composition seam

A composition outside this package could build the supervisor and subscribe to
broker publications, but could not hand it a picker: the picker contract is an
unexported interface whose methods use unexported types, and the only
implementation was built by an unexported constructor. Without that value the
supervisor can never resolve a selection into an attachment, so no offline
composition could reach a session.

Export a Picker facade that satisfies the supervisor's contract by construction
and renders the presentation for a composition that owns the terminal writer.
Rendering stays with the composition and reads geometry at render time, because
the terminal's resize channel has one consumer for the whole run: the
supervisor's attachment geometry collector.

The facade necessarily carries the supervisor's own entry points, since that is
what satisfying the contract means in Go; the type documents that a composition
constructs it, assigns it once, and renders it, and never drives those entry
points itself.
commit
eef2c8e6187c262ac04e1363c1dd029f508b41d5
author
brice · · 2026-09-19 12:18 UTC
parent
952412b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +97 -0
A internal/usecase/client/picker_composition.go +61 -0
@@ -0,0 +1,61 @@
1+package client
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// Picker is the composition seam over the client-owned picker.
11+//
12+// A composition constructs one picker and assigns it to
13+// SupervisorConfig.Picker. The supervisor then folds every broker publication
14+// into it and hands it every terminal read from the single input lifetime it
15+// owns, so the picker never starts a reader of its own. The supervisor also
16+// owns raw mode, the terminal writer, and the attachment geometry collector, so
17+// rendering stays with the composition: it calls Render and RenderNotice with
18+// the geometry it reads at render time.
19+//
20+// The picker deliberately does not consume the terminal's resize channel. That
21+// channel has one consumer for the whole run, the supervisor's attachment
22+// geometry collector, so a renderer that also selected on it would compete for
23+// resizes with the attachment that owns the geometry claimant.
24+//
25+// Assigning this value to SupervisorConfig.Picker is what hands the supervisor
26+// the picker contract, so the value necessarily also carries the supervisor's
27+// own entry points (publications, ownership changes, committed keys). A
28+// composition constructs the picker, assigns it once, and renders it; it never
29+// drives those entry points itself, because doing so would bypass the single
30+// input owner the supervisor maintains.
31+type Picker struct {
32+ *pickerController
33+}
34+
35+// Picker is the supervisor's picker seam: the value assigned to
36+// SupervisorConfig.Picker.
37+var _ pickerHost = (*Picker)(nil)
38+
39+// NewPicker composes one client-owned picker. A nil clock uses the system
40+// clock, and a zero freshness uses the catalogue default.
41+func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {
42+ return &Picker{pickerController: newPickerController(clock, freshness)}
43+}
44+
45+// Render returns the current picker frame for size, or nil when no catalogue
46+// has been applied yet.
47+func (p *Picker) Render(size domain.Size) []byte {
48+ if p == nil || p.pickerController == nil {
49+ return nil
50+ }
51+ return p.pickerController.Render(size)
52+}
53+
54+// RenderNotice returns the newest bounded notice frame for size, or nil when no
55+// notice is live. A composition renders it over or beside the picker frame.
56+func (p *Picker) RenderNotice(size domain.Size) []byte {
57+ if p == nil || p.pickerController == nil {
58+ return nil
59+ }
60+ return p.pickerController.RenderNotice(size)
61+}
@@ -0,0 +1,61 @@
1+package client
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// Picker is the composition seam over the client-owned picker.
11+//
12+// A composition constructs one picker and assigns it to
13+// SupervisorConfig.Picker. The supervisor then folds every broker publication
14+// into it and hands it every terminal read from the single input lifetime it
15+// owns, so the picker never starts a reader of its own. The supervisor also
16+// owns raw mode, the terminal writer, and the attachment geometry collector, so
17+// rendering stays with the composition: it calls Render and RenderNotice with
18+// the geometry it reads at render time.
19+//
20+// The picker deliberately does not consume the terminal's resize channel. That
21+// channel has one consumer for the whole run, the supervisor's attachment
22+// geometry collector, so a renderer that also selected on it would compete for
23+// resizes with the attachment that owns the geometry claimant.
24+//
25+// Assigning this value to SupervisorConfig.Picker is what hands the supervisor
26+// the picker contract, so the value necessarily also carries the supervisor's
27+// own entry points (publications, ownership changes, committed keys). A
28+// composition constructs the picker, assigns it once, and renders it; it never
29+// drives those entry points itself, because doing so would bypass the single
30+// input owner the supervisor maintains.
31+type Picker struct {
32+ *pickerController
33+}
34+
35+// Picker is the supervisor's picker seam: the value assigned to
36+// SupervisorConfig.Picker.
37+var _ pickerHost = (*Picker)(nil)
38+
39+// NewPicker composes one client-owned picker. A nil clock uses the system
40+// clock, and a zero freshness uses the catalogue default.
41+func NewPicker(clock ports.Clock, freshness time.Duration) *Picker {
42+ return &Picker{pickerController: newPickerController(clock, freshness)}
43+}
44+
45+// Render returns the current picker frame for size, or nil when no catalogue
46+// has been applied yet.
47+func (p *Picker) Render(size domain.Size) []byte {
48+ if p == nil || p.pickerController == nil {
49+ return nil
50+ }
51+ return p.pickerController.Render(size)
52+}
53+
54+// RenderNotice returns the newest bounded notice frame for size, or nil when no
55+// notice is live. A composition renders it over or beside the picker frame.
56+func (p *Picker) RenderNotice(size domain.Size) []byte {
57+ if p == nil || p.pickerController == nil {
58+ return nil
59+ }
60+ return p.pickerController.RenderNotice(size)
61+}
A internal/usecase/client/picker_composition_external_test.go +36 -0
@@ -0,0 +1,36 @@
1+package client_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/client"
10+)
11+
12+// TestCompositionOutsideThePackageCanBuildThePicker proves the composition seam
13+// works from outside the package. Without it a composition could build the
14+// supervisor and subscribe to broker publications, but never hand it a picker,
15+// so it could never resolve a selection into an attachment.
16+func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
17+ geometry := domain.Size{Cols: 80, Rows: 24}
18+ picker := client.NewPicker(nil, 0)
19+ require.NotNil(t, picker)
20+
21+ // The exact assignment an app composition performs.
22+ cfg := client.SupervisorConfig{Picker: picker}
23+ require.NotNil(t, cfg.Picker)
24+
25+ // Rendering belongs to the composition, so both entry points are callable
26+ // with the geometry the composition reads itself. Nothing has been applied
27+ // yet, so both are empty rather than a frame.
28+ require.Empty(t, picker.Render(geometry))
29+ require.Empty(t, picker.RenderNotice(geometry))
30+
31+ // A composition that never built a picker renders nothing instead of
32+ // panicking.
33+ var absent *client.Picker
34+ require.Empty(t, absent.Render(geometry))
35+ require.Empty(t, absent.RenderNotice(geometry))
36+}
@@ -0,0 +1,36 @@
1+package client_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/usecase/client"
10+)
11+
12+// TestCompositionOutsideThePackageCanBuildThePicker proves the composition seam
13+// works from outside the package. Without it a composition could build the
14+// supervisor and subscribe to broker publications, but never hand it a picker,
15+// so it could never resolve a selection into an attachment.
16+func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
17+ geometry := domain.Size{Cols: 80, Rows: 24}
18+ picker := client.NewPicker(nil, 0)
19+ require.NotNil(t, picker)
20+
21+ // The exact assignment an app composition performs.
22+ cfg := client.SupervisorConfig{Picker: picker}
23+ require.NotNil(t, cfg.Picker)
24+
25+ // Rendering belongs to the composition, so both entry points are callable
26+ // with the geometry the composition reads itself. Nothing has been applied
27+ // yet, so both are empty rather than a frame.
28+ require.Empty(t, picker.Render(geometry))
29+ require.Empty(t, picker.RenderNotice(geometry))
30+
31+ // A composition that never built a picker renders nothing instead of
32+ // panicking.
33+ var absent *client.Picker
34+ require.Empty(t, absent.Render(geometry))
35+ require.Empty(t, absent.RenderNotice(geometry))
36+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close