detached ee2521c

feat(client): restore terminal theme detection and paste coalescing

The broker cutover dropped the old stdin pump, so the attached worker
forwarded raw terminal bytes: OSC 10/11/4 replies and ?997 scheme
notifications leaked into panes, nothing sent protocol.Theme, and a
bracketed paste split across reads reached the daemon in pieces.

attachmentInput ports that pipeline into the attached loop. Each physical
read goes through theme.Scanner and the DECRQM marker scanner. Ordinary
bytes go to the overlay that owns input, otherwise through the paste
coalescer. The palette generation coordinator writes its queries through a
new foreground query seam and sends cleared and definitive Theme messages.
A scheme notification starts a replacement generation. The supervisor keeps
one terminalThemeState, so a replacement attachment restores the retained
colors. Every timer runs on ports.Clock and only wakes the loop, so the
loop stays the single sender.

Test-only coordinator and coalescer helpers move into _test files, and the
unused Scanner.Pending and takePending are deleted.
commit
ee2521cbf1fc52bd81249dc147348dd68b109f16
author
brice · · 2026-09-22 19:58 UTC
parent
25dcf71
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +819 -66
A internal/usecase/client/attachment_input.go +344 -0
@@ -0,0 +1,344 @@
1+package client
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ renderer "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/theme"
13+)
14+
15+// attachmentInput is the attached loop's terminal input path, ported from the
16+// old client's stdin pump. Each physical read is demultiplexed before anything
17+// reaches the session:
18+//
19+// - theme.Scanner strips OSC 10/11/4 color replies and CSI ?997 scheme
20+// notifications;
21+// - paletteMarkerScanner strips DECRQM 2031 boundary replies;
22+// - the remaining ordinary bytes go to an overlay when one owns input, or
23+// through the paste coalescer, which keeps a bracketed paste in one Input.
24+//
25+// Color replies and markers drive the palette generation, whose queries are
26+// written through the foreground and whose results are sent as
27+// protocol.Theme. Only the attached loop calls these methods. The coalescer's
28+// timers run on their own goroutines, but they only queue bytes and wake the
29+// loop, so the loop stays the single sender.
30+type attachmentInput struct {
31+ worker *sessionAttachmentWorker
32+ fg AttachmentForeground
33+ stream ports.BrokerLogicalConnection
34+ picker *attachmentMovePicker
35+ clock ports.Clock
36+ seq uint64
37+
38+ scanner theme.Scanner
39+ markers paletteMarkerScanner
40+ // markerTimer bounds how long an undecided DECRQM prefix (a lone Escape
41+ // included) is withheld before it is forwarded as ordinary input.
42+ markerTimer ports.Timer
43+
44+ paste *pasteCoalescer
45+ mu sync.Mutex
46+ queued [][]byte
47+ wake chan struct{}
48+
49+ // palette is nil when detection is disabled: no retained theme or no
50+ // query seam on the foreground. Replies are stripped either way.
51+ palette *paletteGenerationCoordinator
52+ themes *terminalThemeState
53+ query attachmentQueryForeground
54+ drain paletteDeadline
55+ completion paletteDeadline
56+}
57+
58+// paletteDeadline is the one armed timer of a deadline kind. The coordinator
59+// cancels a replaced generation's deadlines before arming the next, so one
60+// slot per kind is enough.
61+type paletteDeadline struct {
62+ id paletteGenerationID
63+ timer ports.Timer
64+}
65+
66+func (d *paletteDeadline) arm(clock ports.Clock, id paletteGenerationID, delay time.Duration) {
67+ d.stop()
68+ d.id = id
69+ d.timer = clock.NewTimer(delay)
70+}
71+
72+func (d *paletteDeadline) cancel(id paletteGenerationID) {
73+ if d.timer != nil && d.id == id {
74+ d.stop()
75+ }
76+}
77+
78+func (d *paletteDeadline) stop() {
79+ if d.timer != nil {
80+ d.timer.Stop()
81+ }
82+ *d = paletteDeadline{}
83+}
84+
85+func (d *paletteDeadline) c() <-chan time.Time {
86+ if d.timer == nil {
87+ return nil
88+ }
89+ return d.timer.C()
90+}
91+
92+// fired consumes the slot after its timer delivered, returning the event the
93+// coordinator expects.
94+func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerationEvent {
95+ event := paletteGenerationEvent{id: d.id, kind: kind}
96+ *d = paletteDeadline{}
97+ return event
98+}
99+
100+func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
101+ in := &attachmentInput{
102+ worker: w,
103+ fg: fg,
104+ stream: stream,
105+ picker: picker,
106+ clock: w.cfg.Clock,
107+ wake: make(chan struct{}, 1),
108+ themes: w.cfg.Theme,
109+ }
110+ in.paste = newPasteCoalescer(in.clock, in.enqueue)
111+ if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
112+ in.query = query
113+ in.palette = newPaletteGenerationCoordinator()
114+ }
115+ return in
116+}
117+
118+// enqueue is the coalescer's emit callback. It may run on a timer goroutine,
119+// so it only queues and wakes the attached loop.
120+func (in *attachmentInput) enqueue(data []byte) {
121+ if len(data) == 0 {
122+ return
123+ }
124+ in.mu.Lock()
125+ in.queued = append(in.queued, append([]byte(nil), data...))
126+ in.mu.Unlock()
127+ select {
128+ case in.wake <- struct{}{}:
129+ default:
130+ }
131+}
132+
133+// start publishes the cleared theme and writes the first palette query. The
134+// retained definitive colors avoid a neutral flash on a replacement
135+// attachment.
136+func (in *attachmentInput) start(ctx context.Context) error {
137+ if in.palette == nil {
138+ return nil
139+ }
140+ retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })
141+ return in.apply(ctx, in.palette.start(retained, false))
142+}
143+
144+// close stops every timer. Undecided bytes are dropped with the attachment,
145+// as the owner boundary drops every other attachment-held byte.
146+func (in *attachmentInput) close() {
147+ in.paste.Close()
148+ if in.markerTimer != nil {
149+ in.markerTimer.Stop()
150+ in.markerTimer = nil
151+ }
152+ in.drain.stop()
153+ in.completion.stop()
154+}
155+
156+// read demultiplexes one physical terminal read.
157+func (in *attachmentInput) read(ctx context.Context, state outputApplyState, data []byte) error {
158+ var ordinary []byte
159+ if err := in.disarmMarker(ctx, state); err != nil {
160+ return err
161+ }
162+ // Every callback of one read keeps the generation it started with: a
163+ // scheme notification early in the read starts a replacement, and an old
164+ // marker later in the same read must not advance it.
165+ var id paletteGenerationID
166+ if in.palette != nil {
167+ id = in.palette.current.id
168+ }
169+ var events []paletteGenerationEvent
170+ in.scanner.Scan(data, func(kind int, rgb renderer.RGB) {
171+ event := paletteEventForeground
172+ if kind == 11 {
173+ event = paletteEventBackground
174+ }
175+ events = append(events, paletteGenerationEvent{id: id, kind: event, rgb: rgb})
176+ }, func(slot int, rgb renderer.RGB) {
177+ if slot8, ok := paletteSlot(slot); ok {
178+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot8, rgb: rgb})
179+ }
180+ }, func(light bool) {
181+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventScheme, light: light})
182+ }, func(bytes []byte) {
183+ in.markers.scan(bytes, func(b []byte) { ordinary = append(ordinary, b...) }, func() {
184+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventMarker})
185+ })
186+ })
187+ for _, event := range events {
188+ if err := in.handle(ctx, event); err != nil {
189+ return err
190+ }
191+ }
192+ if in.markers.hasPendingPrefix() {
193+ in.markerTimer = in.clock.NewTimer(paletteMarkerAmbiguityDeadline)
194+ }
195+ return in.route(ctx, state, ordinary)
196+}
197+
198+// flushHeld forwards every undecided ordinary byte before an automation batch,
199+// so driver input never overtakes a keypress still being disambiguated. An
200+// open bracketed paste is left alone.
201+func (in *attachmentInput) flushHeld(ctx context.Context, state outputApplyState) error {
202+ if in.markerTimer != nil {
203+ in.markerTimer.Stop()
204+ in.markerTimer = nil
205+ }
206+ var ordinary []byte
207+ collect := func(b []byte) { ordinary = append(ordinary, b...) }
208+ in.scanner.EndBatch(func(b []byte) {
209+ in.markers.scan(b, collect, func() {})
210+ })
211+ in.markers.flush(collect)
212+ if err := in.route(ctx, state, ordinary); err != nil {
213+ return err
214+ }
215+ in.paste.EndBatch()
216+ return in.flush(ctx)
217+}
218+
219+// disarmMarker stops the ambiguity deadline before a later read. A deadline
220+// that already fired wins, so bytes due as ordinary input are never
221+// retroactively consumed as a marker.
222+func (in *attachmentInput) disarmMarker(ctx context.Context, state outputApplyState) error {
223+ if in.markerTimer == nil {
224+ return nil
225+ }
226+ timer := in.markerTimer
227+ select {
228+ case <-timer.C():
229+ return in.markerExpired(ctx, state)
230+ default:
231+ }
232+ if !timer.Stop() {
233+ return in.markerExpired(ctx, state)
234+ }
235+ in.markerTimer = nil
236+ return nil
237+}
238+
239+func (in *attachmentInput) markerC() <-chan time.Time {
240+ if in.markerTimer == nil {
241+ return nil
242+ }
243+ return in.markerTimer.C()
244+}
245+
246+// markerExpired forwards a withheld DECRQM prefix as ordinary input.
247+func (in *attachmentInput) markerExpired(ctx context.Context, state outputApplyState) error {
248+ in.markerTimer = nil
249+ var ordinary []byte
250+ in.markers.flush(func(b []byte) { ordinary = append(ordinary, b...) })
251+ return in.route(ctx, state, ordinary)
252+}
253+
254+// route hands ordinary bytes to the overlay that owns input, or to the paste
255+// coalescer on their way to the session.
256+func (in *attachmentInput) route(ctx context.Context, state outputApplyState, data []byte) error {
257+ if len(data) == 0 {
258+ return nil
259+ }
260+ consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
261+ if err != nil || consumed {
262+ return err
263+ }
264+ in.paste.Scan(data)
265+ return in.flush(ctx)
266+}
267+
268+// flush sends every queued coalescer emission as one session Input each.
269+func (in *attachmentInput) flush(ctx context.Context) error {
270+ in.mu.Lock()
271+ queued := in.queued
272+ in.queued = nil
273+ in.mu.Unlock()
274+ for _, data := range queued {
275+ if err := in.send(ctx, protocol.Input{InputSeq: in.nextSeq(), Data: data}); err != nil {
276+ return err
277+ }
278+ }
279+ return nil
280+}
281+
282+func (in *attachmentInput) nextSeq() uint64 {
283+ in.seq++
284+ return in.seq
285+}
286+
287+func (in *attachmentInput) send(ctx context.Context, message protocol.ClientMessage) error {
288+ return in.worker.send(ctx, in.fg, in.stream, message)
289+}
290+
291+// handle applies one palette event. A scheme notification starts a
292+// replacement generation from the retained colors.
293+func (in *attachmentInput) handle(ctx context.Context, event paletteGenerationEvent) error {
294+ if in.palette == nil {
295+ return nil
296+ }
297+ if event.kind == paletteEventScheme {
298+ retained := in.themes.update(func(current *protocol.Theme) {
299+ current.SchemeKnown = true
300+ current.Light = event.light
301+ })
302+ return in.apply(ctx, in.palette.start(retained, true))
303+ }
304+ return in.apply(ctx, in.palette.handle(event))
305+}
306+
307+// drainFired and completionFired feed an expired palette deadline back to the
308+// coordinator.
309+func (in *attachmentInput) drainFired(ctx context.Context) error {
310+ return in.handle(ctx, in.drain.fired(paletteEventDrainDeadline))
311+}
312+
313+func (in *attachmentInput) completionFired(ctx context.Context) error {
314+ return in.handle(ctx, in.completion.fired(paletteEventCompletionDeadline))
315+}
316+
317+func (in *attachmentInput) apply(ctx context.Context, actions []paletteGenerationAction) error {
318+ for _, action := range actions {
319+ switch action.kind {
320+ case paletteActionPublishCleared:
321+ if err := in.send(ctx, action.theme); err != nil {
322+ return fmt.Errorf("vev: publishing theme: %w", err)
323+ }
324+ case paletteActionPublishFinal:
325+ in.themes.update(func(current *protocol.Theme) { *current = action.theme })
326+ if err := in.send(ctx, action.theme); err != nil {
327+ return fmt.Errorf("vev: publishing theme: %w", err)
328+ }
329+ case paletteActionWriteDrain, paletteActionWriteBatch:
330+ if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil {
331+ return fmt.Errorf("vev: writing palette query: %w", err)
332+ }
333+ case paletteActionArmDrainDeadline:
334+ in.drain.arm(in.clock, action.id, action.deadline)
335+ case paletteActionArmCompletionDeadline:
336+ in.completion.arm(in.clock, action.id, action.deadline)
337+ case paletteActionCancelDrainDeadline:
338+ in.drain.cancel(action.id)
339+ case paletteActionCancelCompletionDeadline:
340+ in.completion.cancel(action.id)
341+ }
342+ }
343+ return nil
344+}
@@ -0,0 +1,344 @@
1+package client
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ renderer "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/theme"
13+)
14+
15+// attachmentInput is the attached loop's terminal input path, ported from the
16+// old client's stdin pump. Each physical read is demultiplexed before anything
17+// reaches the session:
18+//
19+// - theme.Scanner strips OSC 10/11/4 color replies and CSI ?997 scheme
20+// notifications;
21+// - paletteMarkerScanner strips DECRQM 2031 boundary replies;
22+// - the remaining ordinary bytes go to an overlay when one owns input, or
23+// through the paste coalescer, which keeps a bracketed paste in one Input.
24+//
25+// Color replies and markers drive the palette generation, whose queries are
26+// written through the foreground and whose results are sent as
27+// protocol.Theme. Only the attached loop calls these methods. The coalescer's
28+// timers run on their own goroutines, but they only queue bytes and wake the
29+// loop, so the loop stays the single sender.
30+type attachmentInput struct {
31+ worker *sessionAttachmentWorker
32+ fg AttachmentForeground
33+ stream ports.BrokerLogicalConnection
34+ picker *attachmentMovePicker
35+ clock ports.Clock
36+ seq uint64
37+
38+ scanner theme.Scanner
39+ markers paletteMarkerScanner
40+ // markerTimer bounds how long an undecided DECRQM prefix (a lone Escape
41+ // included) is withheld before it is forwarded as ordinary input.
42+ markerTimer ports.Timer
43+
44+ paste *pasteCoalescer
45+ mu sync.Mutex
46+ queued [][]byte
47+ wake chan struct{}
48+
49+ // palette is nil when detection is disabled: no retained theme or no
50+ // query seam on the foreground. Replies are stripped either way.
51+ palette *paletteGenerationCoordinator
52+ themes *terminalThemeState
53+ query attachmentQueryForeground
54+ drain paletteDeadline
55+ completion paletteDeadline
56+}
57+
58+// paletteDeadline is the one armed timer of a deadline kind. The coordinator
59+// cancels a replaced generation's deadlines before arming the next, so one
60+// slot per kind is enough.
61+type paletteDeadline struct {
62+ id paletteGenerationID
63+ timer ports.Timer
64+}
65+
66+func (d *paletteDeadline) arm(clock ports.Clock, id paletteGenerationID, delay time.Duration) {
67+ d.stop()
68+ d.id = id
69+ d.timer = clock.NewTimer(delay)
70+}
71+
72+func (d *paletteDeadline) cancel(id paletteGenerationID) {
73+ if d.timer != nil && d.id == id {
74+ d.stop()
75+ }
76+}
77+
78+func (d *paletteDeadline) stop() {
79+ if d.timer != nil {
80+ d.timer.Stop()
81+ }
82+ *d = paletteDeadline{}
83+}
84+
85+func (d *paletteDeadline) c() <-chan time.Time {
86+ if d.timer == nil {
87+ return nil
88+ }
89+ return d.timer.C()
90+}
91+
92+// fired consumes the slot after its timer delivered, returning the event the
93+// coordinator expects.
94+func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerationEvent {
95+ event := paletteGenerationEvent{id: d.id, kind: kind}
96+ *d = paletteDeadline{}
97+ return event
98+}
99+
100+func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
101+ in := &attachmentInput{
102+ worker: w,
103+ fg: fg,
104+ stream: stream,
105+ picker: picker,
106+ clock: w.cfg.Clock,
107+ wake: make(chan struct{}, 1),
108+ themes: w.cfg.Theme,
109+ }
110+ in.paste = newPasteCoalescer(in.clock, in.enqueue)
111+ if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
112+ in.query = query
113+ in.palette = newPaletteGenerationCoordinator()
114+ }
115+ return in
116+}
117+
118+// enqueue is the coalescer's emit callback. It may run on a timer goroutine,
119+// so it only queues and wakes the attached loop.
120+func (in *attachmentInput) enqueue(data []byte) {
121+ if len(data) == 0 {
122+ return
123+ }
124+ in.mu.Lock()
125+ in.queued = append(in.queued, append([]byte(nil), data...))
126+ in.mu.Unlock()
127+ select {
128+ case in.wake <- struct{}{}:
129+ default:
130+ }
131+}
132+
133+// start publishes the cleared theme and writes the first palette query. The
134+// retained definitive colors avoid a neutral flash on a replacement
135+// attachment.
136+func (in *attachmentInput) start(ctx context.Context) error {
137+ if in.palette == nil {
138+ return nil
139+ }
140+ retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })
141+ return in.apply(ctx, in.palette.start(retained, false))
142+}
143+
144+// close stops every timer. Undecided bytes are dropped with the attachment,
145+// as the owner boundary drops every other attachment-held byte.
146+func (in *attachmentInput) close() {
147+ in.paste.Close()
148+ if in.markerTimer != nil {
149+ in.markerTimer.Stop()
150+ in.markerTimer = nil
151+ }
152+ in.drain.stop()
153+ in.completion.stop()
154+}
155+
156+// read demultiplexes one physical terminal read.
157+func (in *attachmentInput) read(ctx context.Context, state outputApplyState, data []byte) error {
158+ var ordinary []byte
159+ if err := in.disarmMarker(ctx, state); err != nil {
160+ return err
161+ }
162+ // Every callback of one read keeps the generation it started with: a
163+ // scheme notification early in the read starts a replacement, and an old
164+ // marker later in the same read must not advance it.
165+ var id paletteGenerationID
166+ if in.palette != nil {
167+ id = in.palette.current.id
168+ }
169+ var events []paletteGenerationEvent
170+ in.scanner.Scan(data, func(kind int, rgb renderer.RGB) {
171+ event := paletteEventForeground
172+ if kind == 11 {
173+ event = paletteEventBackground
174+ }
175+ events = append(events, paletteGenerationEvent{id: id, kind: event, rgb: rgb})
176+ }, func(slot int, rgb renderer.RGB) {
177+ if slot8, ok := paletteSlot(slot); ok {
178+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot8, rgb: rgb})
179+ }
180+ }, func(light bool) {
181+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventScheme, light: light})
182+ }, func(bytes []byte) {
183+ in.markers.scan(bytes, func(b []byte) { ordinary = append(ordinary, b...) }, func() {
184+ events = append(events, paletteGenerationEvent{id: id, kind: paletteEventMarker})
185+ })
186+ })
187+ for _, event := range events {
188+ if err := in.handle(ctx, event); err != nil {
189+ return err
190+ }
191+ }
192+ if in.markers.hasPendingPrefix() {
193+ in.markerTimer = in.clock.NewTimer(paletteMarkerAmbiguityDeadline)
194+ }
195+ return in.route(ctx, state, ordinary)
196+}
197+
198+// flushHeld forwards every undecided ordinary byte before an automation batch,
199+// so driver input never overtakes a keypress still being disambiguated. An
200+// open bracketed paste is left alone.
201+func (in *attachmentInput) flushHeld(ctx context.Context, state outputApplyState) error {
202+ if in.markerTimer != nil {
203+ in.markerTimer.Stop()
204+ in.markerTimer = nil
205+ }
206+ var ordinary []byte
207+ collect := func(b []byte) { ordinary = append(ordinary, b...) }
208+ in.scanner.EndBatch(func(b []byte) {
209+ in.markers.scan(b, collect, func() {})
210+ })
211+ in.markers.flush(collect)
212+ if err := in.route(ctx, state, ordinary); err != nil {
213+ return err
214+ }
215+ in.paste.EndBatch()
216+ return in.flush(ctx)
217+}
218+
219+// disarmMarker stops the ambiguity deadline before a later read. A deadline
220+// that already fired wins, so bytes due as ordinary input are never
221+// retroactively consumed as a marker.
222+func (in *attachmentInput) disarmMarker(ctx context.Context, state outputApplyState) error {
223+ if in.markerTimer == nil {
224+ return nil
225+ }
226+ timer := in.markerTimer
227+ select {
228+ case <-timer.C():
229+ return in.markerExpired(ctx, state)
230+ default:
231+ }
232+ if !timer.Stop() {
233+ return in.markerExpired(ctx, state)
234+ }
235+ in.markerTimer = nil
236+ return nil
237+}
238+
239+func (in *attachmentInput) markerC() <-chan time.Time {
240+ if in.markerTimer == nil {
241+ return nil
242+ }
243+ return in.markerTimer.C()
244+}
245+
246+// markerExpired forwards a withheld DECRQM prefix as ordinary input.
247+func (in *attachmentInput) markerExpired(ctx context.Context, state outputApplyState) error {
248+ in.markerTimer = nil
249+ var ordinary []byte
250+ in.markers.flush(func(b []byte) { ordinary = append(ordinary, b...) })
251+ return in.route(ctx, state, ordinary)
252+}
253+
254+// route hands ordinary bytes to the overlay that owns input, or to the paste
255+// coalescer on their way to the session.
256+func (in *attachmentInput) route(ctx context.Context, state outputApplyState, data []byte) error {
257+ if len(data) == 0 {
258+ return nil
259+ }
260+ consumed, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
261+ if err != nil || consumed {
262+ return err
263+ }
264+ in.paste.Scan(data)
265+ return in.flush(ctx)
266+}
267+
268+// flush sends every queued coalescer emission as one session Input each.
269+func (in *attachmentInput) flush(ctx context.Context) error {
270+ in.mu.Lock()
271+ queued := in.queued
272+ in.queued = nil
273+ in.mu.Unlock()
274+ for _, data := range queued {
275+ if err := in.send(ctx, protocol.Input{InputSeq: in.nextSeq(), Data: data}); err != nil {
276+ return err
277+ }
278+ }
279+ return nil
280+}
281+
282+func (in *attachmentInput) nextSeq() uint64 {
283+ in.seq++
284+ return in.seq
285+}
286+
287+func (in *attachmentInput) send(ctx context.Context, message protocol.ClientMessage) error {
288+ return in.worker.send(ctx, in.fg, in.stream, message)
289+}
290+
291+// handle applies one palette event. A scheme notification starts a
292+// replacement generation from the retained colors.
293+func (in *attachmentInput) handle(ctx context.Context, event paletteGenerationEvent) error {
294+ if in.palette == nil {
295+ return nil
296+ }
297+ if event.kind == paletteEventScheme {
298+ retained := in.themes.update(func(current *protocol.Theme) {
299+ current.SchemeKnown = true
300+ current.Light = event.light
301+ })
302+ return in.apply(ctx, in.palette.start(retained, true))
303+ }
304+ return in.apply(ctx, in.palette.handle(event))
305+}
306+
307+// drainFired and completionFired feed an expired palette deadline back to the
308+// coordinator.
309+func (in *attachmentInput) drainFired(ctx context.Context) error {
310+ return in.handle(ctx, in.drain.fired(paletteEventDrainDeadline))
311+}
312+
313+func (in *attachmentInput) completionFired(ctx context.Context) error {
314+ return in.handle(ctx, in.completion.fired(paletteEventCompletionDeadline))
315+}
316+
317+func (in *attachmentInput) apply(ctx context.Context, actions []paletteGenerationAction) error {
318+ for _, action := range actions {
319+ switch action.kind {
320+ case paletteActionPublishCleared:
321+ if err := in.send(ctx, action.theme); err != nil {
322+ return fmt.Errorf("vev: publishing theme: %w", err)
323+ }
324+ case paletteActionPublishFinal:
325+ in.themes.update(func(current *protocol.Theme) { *current = action.theme })
326+ if err := in.send(ctx, action.theme); err != nil {
327+ return fmt.Errorf("vev: publishing theme: %w", err)
328+ }
329+ case paletteActionWriteDrain, paletteActionWriteBatch:
330+ if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil {
331+ return fmt.Errorf("vev: writing palette query: %w", err)
332+ }
333+ case paletteActionArmDrainDeadline:
334+ in.drain.arm(in.clock, action.id, action.deadline)
335+ case paletteActionArmCompletionDeadline:
336+ in.completion.arm(in.clock, action.id, action.deadline)
337+ case paletteActionCancelDrainDeadline:
338+ in.drain.cancel(action.id)
339+ case paletteActionCancelCompletionDeadline:
340+ in.completion.cancel(action.id)
341+ }
342+ }
343+ return nil
344+}
A internal/usecase/client/attachment_input_test.go +305 -0
@@ -0,0 +1,305 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// Ported from the old client's palette_attach_generation_test.go and paste
17+// tests: the attached worker now owns terminal input demultiplexing.
18+
19+// inputTestReader hands each queued chunk to exactly one terminal Read.
20+type inputTestReader struct{ chunks chan []byte }
21+
22+func (r *inputTestReader) Read(p []byte) (int, error) {
23+ data, ok := <-r.chunks
24+ if !ok {
25+ return 0, io.EOF
26+ }
27+ return copy(p, data), nil
28+}
29+
30+type inputHarness struct {
31+ stream *sessionTestStream
32+ term *workerTestTerminal
33+ clock *supervisorTestClock
34+ reader *inputTestReader
35+ events chan AttachmentEvent
36+}
37+
38+// startInputHarness runs one real worker through the real foreground host and
39+// terminal input pump until its initial publication is committed. The worker
40+// clock is separate from the host clock, so every timer on it belongs to the
41+// input path.
42+func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
43+ t.Helper()
44+ reader := &inputTestReader{chunks: make(chan []byte, 16)}
45+ pump := newTerminalInputPump(reader)
46+ pump.start()
47+ t.Cleanup(func() {
48+ pump.stop()
49+ close(reader.chunks)
50+ })
51+ h := &inputHarness{
52+ stream: newSessionTestStream(),
53+ term: newWorkerTestTerminal(),
54+ clock: newSupervisorTestClock(),
55+ reader: reader,
56+ events: make(chan AttachmentEvent, 1),
57+ }
58+ host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})
59+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
60+ cfg.Clock = h.clock
61+ cfg.Theme = themes
62+ cfg.TrueColor = true
63+ worker, err := newSessionAttachmentWorker(cfg)
64+ require.NoError(t, err)
65+ go func() {
66+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, h.stream)
67+ h.events <- event
68+ }()
69+ awaitHello(t, h.stream)
70+ h.stream.deliver(protocol.Welcome{SessionName: "alpha"})
71+ h.stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
72+ return h
73+}
74+
75+func (h *inputHarness) send(data string) { h.reader.chunks <- []byte(data) }
76+
77+func (h *inputHarness) end(t *testing.T) {
78+ t.Helper()
79+ h.stream.deliver(protocol.Detached{Reason: protocol.ReasonDetach})
80+ select {
81+ case event := <-h.events:
82+ require.Equal(t, AttachmentEventEnded, event.Kind)
83+ case <-time.After(5 * time.Second):
84+ t.Fatal("the worker never settled")
85+ }
86+}
87+
88+func (h *inputHarness) themes() []protocol.Theme {
89+ var themes []protocol.Theme
90+ for _, message := range h.stream.messages() {
91+ if theme, ok := message.(protocol.Theme); ok {
92+ themes = append(themes, theme)
93+ }
94+ }
95+ return themes
96+}
97+
98+func (h *inputHarness) inputs() []string {
99+ var inputs []string
100+ for _, message := range h.stream.messages() {
101+ if input, ok := message.(protocol.Input); ok {
102+ inputs = append(inputs, string(input.Data))
103+ }
104+ }
105+ return inputs
106+}
107+
108+// awaitThemes waits for the nth Theme and returns every Theme sent so far.
109+func (h *inputHarness) awaitThemes(t *testing.T, n int) []protocol.Theme {
110+ t.Helper()
111+ require.Eventually(t, func() bool { return len(h.themes()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Theme #%d", n)
112+ return h.themes()
113+}
114+
115+func (h *inputHarness) awaitInputs(t *testing.T, n int) []string {
116+ t.Helper()
117+ require.Eventually(t, func() bool { return len(h.inputs()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Input #%d", n)
118+ return h.inputs()
119+}
120+
121+// awaitQueries waits until query was written n times after the frame.
122+func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) {
123+ t.Helper()
124+ require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)
125+}
126+
127+func paletteReply(foreground, background string, slot uint8, color string) string {
128+ return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
129+}
130+
131+func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
132+ themes := &terminalThemeState{}
133+ first := startInputHarness(t, themes)
134+ first.awaitQueries(t, paletteColorBatch, 1)
135+ require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
136+ cleared := first.awaitThemes(t, 1)[0]
137+ require.True(t, cleared.TrueColor)
138+ require.Zero(t, cleared.PaletteKnown)
139+ require.False(t, cleared.HasForeground)
140+
141+ first.send(paletteReply("#010203", "#040506", 2, "#102030"))
142+ final := first.awaitThemes(t, 2)[1]
143+ require.True(t, final.HasForeground)
144+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
145+ require.True(t, final.HasBackground)
146+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, final.Background)
147+ require.Equal(t, uint16(1<<2), final.PaletteKnown)
148+ require.Equal(t, renderer.RGB{R: 16, G: 32, B: 48}, final.Palette[2])
149+ first.end(t)
150+ require.Empty(t, first.inputs(), "terminal replies never reach the session")
151+
152+ // A replacement attachment clears only the palette: the retained default
153+ // colors avoid a neutral flash, and no restore Theme precedes it.
154+ second := startInputHarness(t, themes)
155+ second.awaitQueries(t, paletteColorBatch, 1)
156+ cleared = second.awaitThemes(t, 1)[0]
157+ require.True(t, cleared.HasForeground)
158+ require.Equal(t, final.Foreground, cleared.Foreground)
159+ require.Equal(t, final.Background, cleared.Background)
160+ require.Zero(t, cleared.PaletteKnown)
161+ require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
162+ second.send(paletteReply("#111213", "#141516", 2, "#202122"))
163+ second.awaitThemes(t, 2)
164+ second.end(t)
165+ require.Len(t, second.themes(), 2)
166+}
167+
168+func TestAttachmentInputSchemeNotificationReplacesPalette(t *testing.T) {
169+ h := startInputHarness(t, &terminalThemeState{})
170+ h.awaitQueries(t, paletteColorBatch, 1)
171+ _ = h.clock.awaitTimer(t) // initial completion deadline, cancelled by the marker
172+ h.send(paletteReply("#010203", "#040506", 2, "#102030"))
173+ h.awaitThemes(t, 2)
174+
175+ h.send("\x1b[?997;2n")
176+ cleared := h.awaitThemes(t, 3)[2]
177+ require.True(t, cleared.SchemeKnown)
178+ require.True(t, cleared.Light)
179+ require.Zero(t, cleared.PaletteKnown)
180+ h.awaitQueries(t, paletteBoundaryQuery, 2) // one inside the first batch, then the drain
181+ drain := h.clock.awaitTimer(t)
182+ require.Equal(t, paletteGenerationDeadline, drain.delay)
183+
184+ // The drain deadline starts the color batch even though no reply arrives,
185+ // and the completion deadline finalizes the partial generation.
186+ drain.fire()
187+ h.awaitQueries(t, paletteColorBatch, 2)
188+ h.clock.awaitTimer(t).fire()
189+ final := h.awaitThemes(t, 4)[3]
190+ require.True(t, final.SchemeKnown)
191+ require.True(t, final.Light)
192+ require.False(t, final.HasForeground)
193+ require.False(t, final.HasBackground)
194+ h.end(t)
195+ require.Len(t, h.themes(), 4, "each generation publishes only a cleared then a definitive Theme")
196+ require.Empty(t, h.inputs(), "the scheme notification never reaches the session")
197+}
198+
199+func TestAttachmentInputLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
200+ h := startInputHarness(t, &terminalThemeState{})
201+ h.awaitQueries(t, paletteColorBatch, 1)
202+ _ = h.clock.awaitTimer(t)
203+ h.send(paletteReply("#010203", "#040506", 2, "#102030"))
204+ h.awaitThemes(t, 2)
205+
206+ h.send("\x1b[?997;1n")
207+ h.awaitThemes(t, 3)
208+ h.clock.awaitTimer(t).fire() // drain deadline
209+ h.awaitQueries(t, paletteColorBatch, 2)
210+ _ = h.clock.awaitTimer(t) // completion deadline, cancelled by the second marker
211+
212+ // The drain's stale reports arrive before its late boundary and must not
213+ // contaminate the replacement. Reports after it belong to the batch.
214+ h.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
215+ final := h.awaitThemes(t, 4)[3]
216+ require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
217+ require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
218+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
219+ h.end(t)
220+}
221+
222+func TestAttachmentInputForwardsStandaloneEscapeAfterAmbiguityDeadlines(t *testing.T) {
223+ h := startInputHarness(t, &terminalThemeState{})
224+ h.awaitQueries(t, paletteColorBatch, 1)
225+ _ = h.clock.awaitTimer(t) // initial completion deadline
226+
227+ // A bare Escape may start a DECRQM reply, then a bracketed-paste marker.
228+ // Each window elapses on the clock; no later read is needed.
229+ h.send("\x1b")
230+ marker := h.clock.awaitTimer(t)
231+ require.Equal(t, paletteMarkerAmbiguityDeadline, marker.delay)
232+ require.Empty(t, h.inputs())
233+ marker.fire()
234+ paste := h.clock.awaitTimer(t)
235+ require.Equal(t, pasteFlushDelay, paste.delay)
236+ require.Empty(t, h.inputs())
237+ paste.fire()
238+ require.Equal(t, []string{"\x1b"}, h.awaitInputs(t, 1))
239+ h.end(t)
240+}
241+
242+func TestAttachmentInputConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
243+ h := startInputHarness(t, &terminalThemeState{})
244+ h.awaitQueries(t, paletteColorBatch, 1)
245+ _ = h.clock.awaitTimer(t)
246+ h.awaitThemes(t, 1)
247+
248+ h.send("before\x1b[?2031;")
249+ marker := h.clock.awaitTimer(t)
250+ h.send("1$yafter")
251+ final := h.awaitThemes(t, 2)[1]
252+ require.False(t, final.HasForeground)
253+ require.Equal(t, "beforeafter", strings.Join(h.awaitInputs(t, 2), ""))
254+ require.True(t, marker.stopped(), "the completed marker cancels its ambiguity deadline")
255+ h.end(t)
256+}
257+
258+func TestAttachmentInputPartialOSCPublishesNothingUntilComplete(t *testing.T) {
259+ h := startInputHarness(t, &terminalThemeState{})
260+ h.awaitQueries(t, paletteColorBatch, 1)
261+ h.awaitThemes(t, 1)
262+
263+ h.send("\x1b]10;#0102")
264+ h.send("03\az")
265+ require.Equal(t, []string{"z"}, h.awaitInputs(t, 1))
266+ require.Len(t, h.themes(), 1, "a partial OSC reply never publishes Theme")
267+
268+ h.send("\x1b[?2031;1$y")
269+ final := h.awaitThemes(t, 2)[1]
270+ require.True(t, final.HasForeground)
271+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
272+ h.end(t)
273+ require.Equal(t, []string{"z"}, h.inputs())
274+}
275+
276+// TestAttachmentInputFraming covers the session-bound framing without palette
277+// detection: replies are stripped, and a bracketed paste split across reads
278+// or markers reaches the daemon as one Input.
279+func TestAttachmentInputFraming(t *testing.T) {
280+ tests := []struct {
281+ name string
282+ reads []string
283+ want []string
284+ }{
285+ {name: "plain keys", reads: []string{"abc"}, want: []string{"abc"}},
286+ {name: "mouse report stays intact", reads: []string{"\x1b[<0;1;1M"}, want: []string{"\x1b[<0;1;1M"}},
287+ {name: "scheme notification and color reply are stripped", reads: []string{"a\x1b[?997;1nb\x1b]11;rgb:0000/0000/0000\ac"}, want: []string{"abc"}},
288+ {name: "paste split across reads", reads: []string{"\x1b[200~hello\n", "world\x1b[201~"}, want: []string{"\x1b[200~hello\nworld\x1b[201~"}},
289+ {name: "paste split inside the opening marker", reads: []string{"ab\x1b[20", "0~x\x1b[201~z"}, want: []string{"ab", "\x1b[200~x\x1b[201~", "z"}},
290+ {name: "paste split after its escape", reads: []string{"ab\x1b", "[200~x\x1b[201~"}, want: []string{"ab", "\x1b[200~x\x1b[201~"}},
291+ }
292+ for _, tt := range tests {
293+ t.Run(tt.name, func(t *testing.T) {
294+ h := startInputHarness(t, nil)
295+ for _, read := range tt.reads {
296+ h.send(read)
297+ }
298+ require.Equal(t, tt.want, h.awaitInputs(t, len(tt.want)))
299+ h.end(t)
300+ require.Equal(t, tt.want, h.inputs())
301+ require.Empty(t, h.themes(), "no Theme without a retained theme state")
302+ require.Equal(t, "\x1b[Hready", h.term.written(), "no palette query without a retained theme state")
303+ })
304+ }
305+}
@@ -0,0 +1,305 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+// Ported from the old client's palette_attach_generation_test.go and paste
17+// tests: the attached worker now owns terminal input demultiplexing.
18+
19+// inputTestReader hands each queued chunk to exactly one terminal Read.
20+type inputTestReader struct{ chunks chan []byte }
21+
22+func (r *inputTestReader) Read(p []byte) (int, error) {
23+ data, ok := <-r.chunks
24+ if !ok {
25+ return 0, io.EOF
26+ }
27+ return copy(p, data), nil
28+}
29+
30+type inputHarness struct {
31+ stream *sessionTestStream
32+ term *workerTestTerminal
33+ clock *supervisorTestClock
34+ reader *inputTestReader
35+ events chan AttachmentEvent
36+}
37+
38+// startInputHarness runs one real worker through the real foreground host and
39+// terminal input pump until its initial publication is committed. The worker
40+// clock is separate from the host clock, so every timer on it belongs to the
41+// input path.
42+func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
43+ t.Helper()
44+ reader := &inputTestReader{chunks: make(chan []byte, 16)}
45+ pump := newTerminalInputPump(reader)
46+ pump.start()
47+ t.Cleanup(func() {
48+ pump.stop()
49+ close(reader.chunks)
50+ })
51+ h := &inputHarness{
52+ stream: newSessionTestStream(),
53+ term: newWorkerTestTerminal(),
54+ clock: newSupervisorTestClock(),
55+ reader: reader,
56+ events: make(chan AttachmentEvent, 1),
57+ }
58+ host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})
59+ cfg := sessionTestWorkerConfig(sessionTestRequest(true))
60+ cfg.Clock = h.clock
61+ cfg.Theme = themes
62+ cfg.TrueColor = true
63+ worker, err := newSessionAttachmentWorker(cfg)
64+ require.NoError(t, err)
65+ go func() {
66+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, h.stream)
67+ h.events <- event
68+ }()
69+ awaitHello(t, h.stream)
70+ h.stream.deliver(protocol.Welcome{SessionName: "alpha"})
71+ h.stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
72+ return h
73+}
74+
75+func (h *inputHarness) send(data string) { h.reader.chunks <- []byte(data) }
76+
77+func (h *inputHarness) end(t *testing.T) {
78+ t.Helper()
79+ h.stream.deliver(protocol.Detached{Reason: protocol.ReasonDetach})
80+ select {
81+ case event := <-h.events:
82+ require.Equal(t, AttachmentEventEnded, event.Kind)
83+ case <-time.After(5 * time.Second):
84+ t.Fatal("the worker never settled")
85+ }
86+}
87+
88+func (h *inputHarness) themes() []protocol.Theme {
89+ var themes []protocol.Theme
90+ for _, message := range h.stream.messages() {
91+ if theme, ok := message.(protocol.Theme); ok {
92+ themes = append(themes, theme)
93+ }
94+ }
95+ return themes
96+}
97+
98+func (h *inputHarness) inputs() []string {
99+ var inputs []string
100+ for _, message := range h.stream.messages() {
101+ if input, ok := message.(protocol.Input); ok {
102+ inputs = append(inputs, string(input.Data))
103+ }
104+ }
105+ return inputs
106+}
107+
108+// awaitThemes waits for the nth Theme and returns every Theme sent so far.
109+func (h *inputHarness) awaitThemes(t *testing.T, n int) []protocol.Theme {
110+ t.Helper()
111+ require.Eventually(t, func() bool { return len(h.themes()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Theme #%d", n)
112+ return h.themes()
113+}
114+
115+func (h *inputHarness) awaitInputs(t *testing.T, n int) []string {
116+ t.Helper()
117+ require.Eventually(t, func() bool { return len(h.inputs()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Input #%d", n)
118+ return h.inputs()
119+}
120+
121+// awaitQueries waits until query was written n times after the frame.
122+func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) {
123+ t.Helper()
124+ require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)
125+}
126+
127+func paletteReply(foreground, background string, slot uint8, color string) string {
128+ return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
129+}
130+
131+func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
132+ themes := &terminalThemeState{}
133+ first := startInputHarness(t, themes)
134+ first.awaitQueries(t, paletteColorBatch, 1)
135+ require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
136+ cleared := first.awaitThemes(t, 1)[0]
137+ require.True(t, cleared.TrueColor)
138+ require.Zero(t, cleared.PaletteKnown)
139+ require.False(t, cleared.HasForeground)
140+
141+ first.send(paletteReply("#010203", "#040506", 2, "#102030"))
142+ final := first.awaitThemes(t, 2)[1]
143+ require.True(t, final.HasForeground)
144+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
145+ require.True(t, final.HasBackground)
146+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, final.Background)
147+ require.Equal(t, uint16(1<<2), final.PaletteKnown)
148+ require.Equal(t, renderer.RGB{R: 16, G: 32, B: 48}, final.Palette[2])
149+ first.end(t)
150+ require.Empty(t, first.inputs(), "terminal replies never reach the session")
151+
152+ // A replacement attachment clears only the palette: the retained default
153+ // colors avoid a neutral flash, and no restore Theme precedes it.
154+ second := startInputHarness(t, themes)
155+ second.awaitQueries(t, paletteColorBatch, 1)
156+ cleared = second.awaitThemes(t, 1)[0]
157+ require.True(t, cleared.HasForeground)
158+ require.Equal(t, final.Foreground, cleared.Foreground)
159+ require.Equal(t, final.Background, cleared.Background)
160+ require.Zero(t, cleared.PaletteKnown)
161+ require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
162+ second.send(paletteReply("#111213", "#141516", 2, "#202122"))
163+ second.awaitThemes(t, 2)
164+ second.end(t)
165+ require.Len(t, second.themes(), 2)
166+}
167+
168+func TestAttachmentInputSchemeNotificationReplacesPalette(t *testing.T) {
169+ h := startInputHarness(t, &terminalThemeState{})
170+ h.awaitQueries(t, paletteColorBatch, 1)
171+ _ = h.clock.awaitTimer(t) // initial completion deadline, cancelled by the marker
172+ h.send(paletteReply("#010203", "#040506", 2, "#102030"))
173+ h.awaitThemes(t, 2)
174+
175+ h.send("\x1b[?997;2n")
176+ cleared := h.awaitThemes(t, 3)[2]
177+ require.True(t, cleared.SchemeKnown)
178+ require.True(t, cleared.Light)
179+ require.Zero(t, cleared.PaletteKnown)
180+ h.awaitQueries(t, paletteBoundaryQuery, 2) // one inside the first batch, then the drain
181+ drain := h.clock.awaitTimer(t)
182+ require.Equal(t, paletteGenerationDeadline, drain.delay)
183+
184+ // The drain deadline starts the color batch even though no reply arrives,
185+ // and the completion deadline finalizes the partial generation.
186+ drain.fire()
187+ h.awaitQueries(t, paletteColorBatch, 2)
188+ h.clock.awaitTimer(t).fire()
189+ final := h.awaitThemes(t, 4)[3]
190+ require.True(t, final.SchemeKnown)
191+ require.True(t, final.Light)
192+ require.False(t, final.HasForeground)
193+ require.False(t, final.HasBackground)
194+ h.end(t)
195+ require.Len(t, h.themes(), 4, "each generation publishes only a cleared then a definitive Theme")
196+ require.Empty(t, h.inputs(), "the scheme notification never reaches the session")
197+}
198+
199+func TestAttachmentInputLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
200+ h := startInputHarness(t, &terminalThemeState{})
201+ h.awaitQueries(t, paletteColorBatch, 1)
202+ _ = h.clock.awaitTimer(t)
203+ h.send(paletteReply("#010203", "#040506", 2, "#102030"))
204+ h.awaitThemes(t, 2)
205+
206+ h.send("\x1b[?997;1n")
207+ h.awaitThemes(t, 3)
208+ h.clock.awaitTimer(t).fire() // drain deadline
209+ h.awaitQueries(t, paletteColorBatch, 2)
210+ _ = h.clock.awaitTimer(t) // completion deadline, cancelled by the second marker
211+
212+ // The drain's stale reports arrive before its late boundary and must not
213+ // contaminate the replacement. Reports after it belong to the batch.
214+ h.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
215+ final := h.awaitThemes(t, 4)[3]
216+ require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
217+ require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
218+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
219+ h.end(t)
220+}
221+
222+func TestAttachmentInputForwardsStandaloneEscapeAfterAmbiguityDeadlines(t *testing.T) {
223+ h := startInputHarness(t, &terminalThemeState{})
224+ h.awaitQueries(t, paletteColorBatch, 1)
225+ _ = h.clock.awaitTimer(t) // initial completion deadline
226+
227+ // A bare Escape may start a DECRQM reply, then a bracketed-paste marker.
228+ // Each window elapses on the clock; no later read is needed.
229+ h.send("\x1b")
230+ marker := h.clock.awaitTimer(t)
231+ require.Equal(t, paletteMarkerAmbiguityDeadline, marker.delay)
232+ require.Empty(t, h.inputs())
233+ marker.fire()
234+ paste := h.clock.awaitTimer(t)
235+ require.Equal(t, pasteFlushDelay, paste.delay)
236+ require.Empty(t, h.inputs())
237+ paste.fire()
238+ require.Equal(t, []string{"\x1b"}, h.awaitInputs(t, 1))
239+ h.end(t)
240+}
241+
242+func TestAttachmentInputConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
243+ h := startInputHarness(t, &terminalThemeState{})
244+ h.awaitQueries(t, paletteColorBatch, 1)
245+ _ = h.clock.awaitTimer(t)
246+ h.awaitThemes(t, 1)
247+
248+ h.send("before\x1b[?2031;")
249+ marker := h.clock.awaitTimer(t)
250+ h.send("1$yafter")
251+ final := h.awaitThemes(t, 2)[1]
252+ require.False(t, final.HasForeground)
253+ require.Equal(t, "beforeafter", strings.Join(h.awaitInputs(t, 2), ""))
254+ require.True(t, marker.stopped(), "the completed marker cancels its ambiguity deadline")
255+ h.end(t)
256+}
257+
258+func TestAttachmentInputPartialOSCPublishesNothingUntilComplete(t *testing.T) {
259+ h := startInputHarness(t, &terminalThemeState{})
260+ h.awaitQueries(t, paletteColorBatch, 1)
261+ h.awaitThemes(t, 1)
262+
263+ h.send("\x1b]10;#0102")
264+ h.send("03\az")
265+ require.Equal(t, []string{"z"}, h.awaitInputs(t, 1))
266+ require.Len(t, h.themes(), 1, "a partial OSC reply never publishes Theme")
267+
268+ h.send("\x1b[?2031;1$y")
269+ final := h.awaitThemes(t, 2)[1]
270+ require.True(t, final.HasForeground)
271+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
272+ h.end(t)
273+ require.Equal(t, []string{"z"}, h.inputs())
274+}
275+
276+// TestAttachmentInputFraming covers the session-bound framing without palette
277+// detection: replies are stripped, and a bracketed paste split across reads
278+// or markers reaches the daemon as one Input.
279+func TestAttachmentInputFraming(t *testing.T) {
280+ tests := []struct {
281+ name string
282+ reads []string
283+ want []string
284+ }{
285+ {name: "plain keys", reads: []string{"abc"}, want: []string{"abc"}},
286+ {name: "mouse report stays intact", reads: []string{"\x1b[<0;1;1M"}, want: []string{"\x1b[<0;1;1M"}},
287+ {name: "scheme notification and color reply are stripped", reads: []string{"a\x1b[?997;1nb\x1b]11;rgb:0000/0000/0000\ac"}, want: []string{"abc"}},
288+ {name: "paste split across reads", reads: []string{"\x1b[200~hello\n", "world\x1b[201~"}, want: []string{"\x1b[200~hello\nworld\x1b[201~"}},
289+ {name: "paste split inside the opening marker", reads: []string{"ab\x1b[20", "0~x\x1b[201~z"}, want: []string{"ab", "\x1b[200~x\x1b[201~", "z"}},
290+ {name: "paste split after its escape", reads: []string{"ab\x1b", "[200~x\x1b[201~"}, want: []string{"ab", "\x1b[200~x\x1b[201~"}},
291+ }
292+ for _, tt := range tests {
293+ t.Run(tt.name, func(t *testing.T) {
294+ h := startInputHarness(t, nil)
295+ for _, read := range tt.reads {
296+ h.send(read)
297+ }
298+ require.Equal(t, tt.want, h.awaitInputs(t, len(tt.want)))
299+ h.end(t)
300+ require.Equal(t, tt.want, h.inputs())
301+ require.Empty(t, h.themes(), "no Theme without a retained theme state")
302+ require.Equal(t, "\x1b[Hready", h.term.written(), "no palette query without a retained theme state")
303+ })
304+ }
305+}
M internal/usecase/client/attachment_session.go +46 -5
@@ -68,9 +68,14 @@ type sessionAttachmentConfig struct {
6868 // BeforeAttached runs after the initial frame commits but before attached
6969 // presentation becomes observable. A failure keeps the run unattached.
7070 BeforeAttached func() error
71- // Clock bounds the move picker's lone-escape window. A nil clock uses the
72- // system clock.
71+ // Clock bounds the move picker's lone-escape window and the terminal input
72+ // timers (DECRQM ambiguity, paste framing, palette deadlines). A nil clock
73+ // uses the system clock.
7374 Clock ports.Clock
75+ // Theme retains terminal-reported colors across attachments. When set,
76+ // the worker queries the terminal palette and sends protocol.Theme; when
77+ // nil, replies are still stripped from input but no query is written.
78+ Theme *terminalThemeState
7479 }
7580
7681 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -287,9 +292,29 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
287292 }
288293 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
289294 defer picker.stopEscape()
290- var inputSeq uint64
295+ input := newAttachmentInput(w, fg, stream, picker)
296+ defer input.close()
297+ if err := input.start(ctx); err != nil {
298+ return w.settle(ctx, fg, stream, token, err)
299+ }
291300 for {
292301 select {
302+ case <-input.wake:
303+ if err := input.flush(ctx); err != nil {
304+ return w.settle(ctx, fg, stream, token, err)
305+ }
306+ case <-input.markerC():
307+ if err := input.markerExpired(ctx, state); err != nil {
308+ return w.settle(ctx, fg, stream, token, err)
309+ }
310+ case <-input.drain.c():
311+ if err := input.drainFired(ctx); err != nil {
312+ return w.settle(ctx, fg, stream, token, err)
313+ }
314+ case <-input.completion.c():
315+ if err := input.completionFired(ctx); err != nil {
316+ return w.settle(ctx, fg, stream, token, err)
317+ }
293318 case <-repaint:
294319 // An overlay released the terminal: the picker box is still on screen
295320 // and suppressed frames were never written, so ask the daemon for an
@@ -363,7 +388,24 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
363388 fg.PreserveInput(event.input.Data)
364389 return w.settle(ctx, fg, stream, token, event.input.Err)
365390 }
391+ if event.input != nil && event.input.actionID == 0 {
392+ // A physical read: replies are stripped and ordinary bytes
393+ // reach the overlay or the session inside read.
394+ if err := input.read(ctx, state, event.input.Data); err != nil {
395+ // Part of the read may already be delivered or held, so
396+ // it is committed rather than replayed.
397+ fg.AckInput()
398+ return w.settle(ctx, fg, stream, token, err)
399+ }
400+ fg.AckInput()
401+ continue
402+ }
366403 if event.input != nil {
404+ // A driver batch: bytes held for disambiguation go first.
405+ if err := input.flushHeld(ctx, state); err != nil {
406+ fg.PreserveInput(event.input.Data)
407+ return w.settle(ctx, fg, stream, token, err)
408+ }
367409 consumed, err := picker.consumeInput(ctx, state, *event.input)
368410 if err != nil {
369411 fg.PreserveInput(event.input.Data)
@@ -381,8 +423,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
381423 }
382424 continue
383425 }
384- inputSeq++
385- event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
426+ event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
386427 }
387428 if resize, ok := event.message.(protocol.Resize); ok {
388429 if err := picker.resize(ctx, state, resize.Size); err != nil {
@@ -68,9 +68,14 @@ type sessionAttachmentConfig struct {
68 // BeforeAttached runs after the initial frame commits but before attached68 // BeforeAttached runs after the initial frame commits but before attached
69 // presentation becomes observable. A failure keeps the run unattached.69 // presentation becomes observable. A failure keeps the run unattached.
70 BeforeAttached func() error70 BeforeAttached func() error
71- // Clock bounds the move picker's lone-escape window. A nil clock uses the71+ // Clock bounds the move picker's lone-escape window and the terminal input
72- // system clock.72+ // timers (DECRQM ambiguity, paste framing, palette deadlines). A nil clock
73+ // uses the system clock.
73 Clock ports.Clock74 Clock ports.Clock
75+ // Theme retains terminal-reported colors across attachments. When set,
76+ // the worker queries the terminal palette and sends protocol.Theme; when
77+ // nil, replies are still stripped from input but no query is written.
78+ Theme *terminalThemeState
74 }79 }
75 80
76 // sessionAttachmentWorker implements AttachmentWorker for one broker logical81 // sessionAttachmentWorker implements AttachmentWorker for one broker logical
@@ -287,9 +292,29 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
287 }292 }
288 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}293 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
289 defer picker.stopEscape()294 defer picker.stopEscape()
290- var inputSeq uint64295+ input := newAttachmentInput(w, fg, stream, picker)
296+ defer input.close()
297+ if err := input.start(ctx); err != nil {
298+ return w.settle(ctx, fg, stream, token, err)
299+ }
291 for {300 for {
292 select {301 select {
302+ case <-input.wake:
303+ if err := input.flush(ctx); err != nil {
304+ return w.settle(ctx, fg, stream, token, err)
305+ }
306+ case <-input.markerC():
307+ if err := input.markerExpired(ctx, state); err != nil {
308+ return w.settle(ctx, fg, stream, token, err)
309+ }
310+ case <-input.drain.c():
311+ if err := input.drainFired(ctx); err != nil {
312+ return w.settle(ctx, fg, stream, token, err)
313+ }
314+ case <-input.completion.c():
315+ if err := input.completionFired(ctx); err != nil {
316+ return w.settle(ctx, fg, stream, token, err)
317+ }
293 case <-repaint:318 case <-repaint:
294 // An overlay released the terminal: the picker box is still on screen319 // An overlay released the terminal: the picker box is still on screen
295 // and suppressed frames were never written, so ask the daemon for an320 // and suppressed frames were never written, so ask the daemon for an
@@ -363,7 +388,24 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
363 fg.PreserveInput(event.input.Data)388 fg.PreserveInput(event.input.Data)
364 return w.settle(ctx, fg, stream, token, event.input.Err)389 return w.settle(ctx, fg, stream, token, event.input.Err)
365 }390 }
391+ if event.input != nil && event.input.actionID == 0 {
392+ // A physical read: replies are stripped and ordinary bytes
393+ // reach the overlay or the session inside read.
394+ if err := input.read(ctx, state, event.input.Data); err != nil {
395+ // Part of the read may already be delivered or held, so
396+ // it is committed rather than replayed.
397+ fg.AckInput()
398+ return w.settle(ctx, fg, stream, token, err)
399+ }
400+ fg.AckInput()
401+ continue
402+ }
366 if event.input != nil {403 if event.input != nil {
404+ // A driver batch: bytes held for disambiguation go first.
405+ if err := input.flushHeld(ctx, state); err != nil {
406+ fg.PreserveInput(event.input.Data)
407+ return w.settle(ctx, fg, stream, token, err)
408+ }
367 consumed, err := picker.consumeInput(ctx, state, *event.input)409 consumed, err := picker.consumeInput(ctx, state, *event.input)
368 if err != nil {410 if err != nil {
369 fg.PreserveInput(event.input.Data)411 fg.PreserveInput(event.input.Data)
@@ -381,8 +423,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
381 }423 }
382 continue424 continue
383 }425 }
384- inputSeq++426+ event.message = protocol.Input{InputSeq: input.nextSeq(), ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
385- event.message = protocol.Input{InputSeq: inputSeq, ActionID: event.input.actionID, Data: append([]byte(nil), event.input.Data...)}
386 }427 }
387 if resize, ok := event.message.(protocol.Resize); ok {428 if resize, ok := event.message.(protocol.Resize); ok {
388 if err := picker.resize(ctx, state, resize.Size); err != nil {429 if err := picker.resize(ctx, state, resize.Size); err != nil {
M internal/usecase/client/attachment_worker.go +41 -0
@@ -849,6 +849,47 @@ func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []b
849849 return f.write(uiContext, data, true)
850850 }
851851
852+// attachmentQueryForeground is the optional terminal-query seam of the real
853+// foreground. Scripted foregrounds may omit it; the worker then skips palette
854+// detection but still strips terminal replies from input.
855+type attachmentQueryForeground interface {
856+ writeTerminalQuery(data []byte) error
857+}
858+
859+var _ attachmentQueryForeground = (*attachmentForeground)(nil)
860+
861+// writeTerminalQuery writes and flushes terminal query bytes (palette probes)
862+// under the output lease. They draw nothing, so neither an overlay nor the UI
863+// observation channel is involved.
864+func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
865+ if f == nil {
866+ return errAttachmentForegroundRevoked
867+ }
868+ var err error
869+ ok := f.lease.send(func() bool {
870+ if !f.authority.actionAuthorized(f) {
871+ return false
872+ }
873+ if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
874+ err = ports.ErrUIUnavailable
875+ return true
876+ }
877+ var n int
878+ n, err = f.term.Out().Write(data)
879+ if err == nil && n != len(data) {
880+ err = io.ErrShortWrite
881+ }
882+ if err == nil {
883+ err = f.term.Flush()
884+ }
885+ return true
886+ })
887+ if !ok {
888+ return errAttachmentForegroundRevoked
889+ }
890+ return err
891+}
892+
852893 // attachmentRun is one in-flight worker generation owned by the supervisor.
853894 type attachmentRun struct {
854895 host *attachmentHost
@@ -849,6 +849,47 @@ func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []b
849 return f.write(uiContext, data, true)849 return f.write(uiContext, data, true)
850 }850 }
851 851
852+// attachmentQueryForeground is the optional terminal-query seam of the real
853+// foreground. Scripted foregrounds may omit it; the worker then skips palette
854+// detection but still strips terminal replies from input.
855+type attachmentQueryForeground interface {
856+ writeTerminalQuery(data []byte) error
857+}
858+
859+var _ attachmentQueryForeground = (*attachmentForeground)(nil)
860+
861+// writeTerminalQuery writes and flushes terminal query bytes (palette probes)
862+// under the output lease. They draw nothing, so neither an overlay nor the UI
863+// observation channel is involved.
864+func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
865+ if f == nil {
866+ return errAttachmentForegroundRevoked
867+ }
868+ var err error
869+ ok := f.lease.send(func() bool {
870+ if !f.authority.actionAuthorized(f) {
871+ return false
872+ }
873+ if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
874+ err = ports.ErrUIUnavailable
875+ return true
876+ }
877+ var n int
878+ n, err = f.term.Out().Write(data)
879+ if err == nil && n != len(data) {
880+ err = io.ErrShortWrite
881+ }
882+ if err == nil {
883+ err = f.term.Flush()
884+ }
885+ return true
886+ })
887+ if !ok {
888+ return errAttachmentForegroundRevoked
889+ }
890+ return err
891+}
892+
852 // attachmentRun is one in-flight worker generation owned by the supervisor.893 // attachmentRun is one in-flight worker generation owned by the supervisor.
853 type attachmentRun struct {894 type attachmentRun struct {
854 host *attachmentHost895 host *attachmentHost
M internal/usecase/client/palette_generation.go +30 -42
@@ -1,7 +1,7 @@
11 package client
22
33 import (
4- "sync/atomic"
4+ "sync"
55 "time"
66
77 renderer "github.com/bnema/vev-vt"
@@ -54,28 +54,43 @@ const (
5454 paletteEventBackground
5555 paletteEventPalette
5656 paletteEventScheme
57- paletteEventUIBatchEnd
5857 )
5958
6059 type paletteGenerationEvent struct {
61- id paletteGenerationID
62- kind paletteGenerationEventKind
63- rgb renderer.RGB
64- slot uint8
65- light bool
66- batchApplied chan struct{}
67- pending *atomic.Int64
60+ id paletteGenerationID
61+ kind paletteGenerationEventKind
62+ rgb renderer.RGB
63+ slot uint8
64+ light bool
6865 }
6966
70-func (e paletteGenerationEvent) applied() {
71- if e.pending != nil {
72- e.pending.Add(-1)
67+// terminalThemeState retains the latest terminal-reported colors across
68+// attachments so a replacement attachment can restore the daemon theme
69+// without relying on another OSC response. The supervisor owns one per run.
70+type terminalThemeState struct {
71+ mu sync.Mutex
72+ theme protocol.Theme
73+}
74+
75+func (s *terminalThemeState) update(update func(*protocol.Theme)) protocol.Theme {
76+ s.mu.Lock()
77+ defer s.mu.Unlock()
78+ update(&s.theme)
79+ return s.theme
80+}
81+
82+// paletteSlot narrows a scanner palette slot to an ANSI slot, rejecting
83+// anything outside 0..15 before the int is converted.
84+func paletteSlot(slot int) (uint8, bool) {
85+ if slot < 0 || slot > 15 {
86+ return 0, false
7387 }
88+ return uint8(slot), true
7489 }
7590
76-// paletteGenerationAction is deliberately declarative. The attach loop is
77-// the sole terminal writer and protocol sender; timer bridges only enqueue a
78-// generation-tagged event back to that loop.
91+// paletteGenerationAction is deliberately declarative. The attached loop is
92+// the sole terminal writer and protocol sender; timers only wake that loop
93+// with a generation-tagged event.
7994 type paletteGenerationActionKind uint8
8095
8196 const (
@@ -244,24 +259,6 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
244259 }
245260 }
246261
247-func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
248- return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
249-}
250-
251-func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
252- c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
253-}
254-
255-func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
256- c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
257-}
258-
259-func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
260- c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
261-}
262-
263-func (c *paletteGenerationCoordinator) finalizedTheme() protocol.Theme { return c.finalized }
264-
265262 // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
266263 // possible prefixes across reads, forwarding all ordinary input exactly once.
267264 type paletteMarkerScanner struct{ pending []byte }
@@ -318,13 +315,4 @@ func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
318315 s.pending = nil
319316 }
320317
321-// takePending transfers only an undecided DECRQM prefix to a replacement
322-// scanner. Callers use it during cancellation after all other callbacks from
323-// the source read have been committed, preventing both loss and replay.
324-func (s *paletteMarkerScanner) takePending() []byte {
325- pending := append([]byte(nil), s.pending...)
326- s.pending = nil
327- return pending
328-}
329-
330318 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }
@@ -1,7 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4- "sync/atomic"4+ "sync"
5 "time"5 "time"
6 6
7 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
@@ -54,28 +54,43 @@ const (
54 paletteEventBackground54 paletteEventBackground
55 paletteEventPalette55 paletteEventPalette
56 paletteEventScheme56 paletteEventScheme
57- paletteEventUIBatchEnd
58 )57 )
59 58
60 type paletteGenerationEvent struct {59 type paletteGenerationEvent struct {
61- id paletteGenerationID60+ id paletteGenerationID
62- kind paletteGenerationEventKind61+ kind paletteGenerationEventKind
63- rgb renderer.RGB62+ rgb renderer.RGB
64- slot uint863+ slot uint8
65- light bool64+ light bool
66- batchApplied chan struct{}
67- pending *atomic.Int64
68 }65 }
69 66
70-func (e paletteGenerationEvent) applied() {67+// terminalThemeState retains the latest terminal-reported colors across
71- if e.pending != nil {68+// attachments so a replacement attachment can restore the daemon theme
72- e.pending.Add(-1)69+// without relying on another OSC response. The supervisor owns one per run.
70+type terminalThemeState struct {
71+ mu sync.Mutex
72+ theme protocol.Theme
73+}
74+
75+func (s *terminalThemeState) update(update func(*protocol.Theme)) protocol.Theme {
76+ s.mu.Lock()
77+ defer s.mu.Unlock()
78+ update(&s.theme)
79+ return s.theme
80+}
81+
82+// paletteSlot narrows a scanner palette slot to an ANSI slot, rejecting
83+// anything outside 0..15 before the int is converted.
84+func paletteSlot(slot int) (uint8, bool) {
85+ if slot < 0 || slot > 15 {
86+ return 0, false
73 }87 }
88+ return uint8(slot), true
74 }89 }
75 90
76-// paletteGenerationAction is deliberately declarative. The attach loop is91+// paletteGenerationAction is deliberately declarative. The attached loop is
77-// the sole terminal writer and protocol sender; timer bridges only enqueue a92+// the sole terminal writer and protocol sender; timers only wake that loop
78-// generation-tagged event back to that loop.93+// with a generation-tagged event.
79 type paletteGenerationActionKind uint894 type paletteGenerationActionKind uint8
80 95
81 const (96 const (
@@ -244,24 +259,6 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
244 }259 }
245 }260 }
246 261
247-func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
248- return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
249-}
250-
251-func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
252- c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
253-}
254-
255-func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
256- c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
257-}
258-
259-func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
260- c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
261-}
262-
263-func (c *paletteGenerationCoordinator) finalizedTheme() protocol.Theme { return c.finalized }
264-
265 // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains262 // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains
266 // possible prefixes across reads, forwarding all ordinary input exactly once.263 // possible prefixes across reads, forwarding all ordinary input exactly once.
267 type paletteMarkerScanner struct{ pending []byte }264 type paletteMarkerScanner struct{ pending []byte }
@@ -318,13 +315,4 @@ func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
318 s.pending = nil315 s.pending = nil
319 }316 }
320 317
321-// takePending transfers only an undecided DECRQM prefix to a replacement
322-// scanner. Callers use it during cancellation after all other callbacks from
323-// the source read have been committed, preventing both loss and replay.
324-func (s *paletteMarkerScanner) takePending() []byte {
325- pending := append([]byte(nil), s.pending...)
326- s.pending = nil
327- return pending
328-}
329-
330 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }318 func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 }
M internal/usecase/client/palette_generation_test.go +19 -0
@@ -246,3 +246,22 @@ func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T)
246246 func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {
247247 require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)
248248 }
249+
250+// Test-only shorthands for feeding one generation's events.
251+func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
252+ return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
253+}
254+
255+func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
256+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
257+}
258+
259+func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
260+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
261+}
262+
263+func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
264+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
265+}
266+
267+func (c *paletteGenerationCoordinator) finalizedTheme() protocol.Theme { return c.finalized }
@@ -246,3 +246,22 @@ func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T)
246 func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {246 func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {
247 require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)247 require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)
248 }248 }
249+
250+// Test-only shorthands for feeding one generation's events.
251+func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
252+ return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
253+}
254+
255+func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
256+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
257+}
258+
259+func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
260+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
261+}
262+
263+func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
264+ c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
265+}
266+
267+func (c *paletteGenerationCoordinator) finalizedTheme() protocol.Theme { return c.finalized }
M internal/usecase/client/paste_batch_test.go +7 -0
@@ -44,3 +44,10 @@ func TestPasteCoalescerBatchBoundaryDoesNotTerminateHumanPaste(t *testing.T) {
4444 require.False(t, coalescer.Idle())
4545 require.False(t, coalescer.EndBatch())
4646 }
47+
48+// Idle reports that the coalescer holds no paste or undecided prefix bytes.
49+func (c *pasteCoalescer) Idle() bool {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0
53+}
@@ -44,3 +44,10 @@ func TestPasteCoalescerBatchBoundaryDoesNotTerminateHumanPaste(t *testing.T) {
44 require.False(t, coalescer.Idle())44 require.False(t, coalescer.Idle())
45 require.False(t, coalescer.EndBatch())45 require.False(t, coalescer.EndBatch())
46 }46 }
47+
48+// Idle reports that the coalescer holds no paste or undecided prefix bytes.
49+func (c *pasteCoalescer) Idle() bool {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0
53+}
M internal/usecase/client/paste_coalescer.go +0 -8
@@ -158,14 +158,6 @@ func (c *pasteCoalescer) Buffering() bool {
158158 return c.buffering || len(c.pending) > 0
159159 }
160160
161-// Idle is the automation admission check. It observes both paste and ordinary
162-// prefix ownership without flushing human input to manufacture a clean boundary.
163-func (c *pasteCoalescer) Idle() bool {
164- c.mu.Lock()
165- defer c.mu.Unlock()
166- return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0
167-}
168-
169161 // EndBatch flushes only ordinary undecided bytes after a validated automated
170162 // batch. An open paste is never terminated here; admission and input validation
171163 // must prevent automation from entering or inheriting one.
@@ -158,14 +158,6 @@ func (c *pasteCoalescer) Buffering() bool {
158 return c.buffering || len(c.pending) > 0158 return c.buffering || len(c.pending) > 0
159 }159 }
160 160
161-// Idle is the automation admission check. It observes both paste and ordinary
162-// prefix ownership without flushing human input to manufacture a clean boundary.
163-func (c *pasteCoalescer) Idle() bool {
164- c.mu.Lock()
165- defer c.mu.Unlock()
166- return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0
167-}
168-
169 // EndBatch flushes only ordinary undecided bytes after a validated automated161 // EndBatch flushes only ordinary undecided bytes after a validated automated
170 // batch. An open paste is never terminated here; admission and input validation162 // batch. An open paste is never terminated here; admission and input validation
171 // must prevent automation from entering or inheriting one.163 // must prevent automation from entering or inheriting one.
M internal/usecase/client/picker_overlay_test.go +14 -1
@@ -310,10 +310,23 @@ func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
310310 name: "a lone escape cancels once its window elapses",
311311 keys: []string{"\x1b"},
312312 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
313+ // The input path first withholds the Escape as a possible
314+ // DECRQM reply prefix; its ambiguity window hands it on.
315+ var marker *supervisorTestTimer
316+ require.Eventually(t, func() bool {
317+ for _, timer := range harness.clock.snapshotTimers() {
318+ if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() {
319+ marker = timer
320+ return true
321+ }
322+ }
323+ return false
324+ }, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed")
325+ marker.fire()
313326 var escape *supervisorTestTimer
314327 require.Eventually(t, func() bool {
315328 for _, timer := range harness.clock.snapshotTimers() {
316- if timer.delay == pickerEscapeDeadline && !timer.stopped() {
329+ if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() {
317330 escape = timer
318331 return true
319332 }
@@ -310,10 +310,23 @@ func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
310 name: "a lone escape cancels once its window elapses",310 name: "a lone escape cancels once its window elapses",
311 keys: []string{"\x1b"},311 keys: []string{"\x1b"},
312 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {312 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
313+ // The input path first withholds the Escape as a possible
314+ // DECRQM reply prefix; its ambiguity window hands it on.
315+ var marker *supervisorTestTimer
316+ require.Eventually(t, func() bool {
317+ for _, timer := range harness.clock.snapshotTimers() {
318+ if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() {
319+ marker = timer
320+ return true
321+ }
322+ }
323+ return false
324+ }, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed")
325+ marker.fire()
313 var escape *supervisorTestTimer326 var escape *supervisorTestTimer
314 require.Eventually(t, func() bool {327 require.Eventually(t, func() bool {
315 for _, timer := range harness.clock.snapshotTimers() {328 for _, timer := range harness.clock.snapshotTimers() {
316- if timer.delay == pickerEscapeDeadline && !timer.stopped() {329+ if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() {
317 escape = timer330 escape = timer
318 return true331 return true
319 }332 }
M internal/usecase/client/resize_presentation_test.go +2 -1
@@ -597,7 +597,8 @@ func TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput(t *tes
597597 // not have driven a connecting repaint at the new geometry.
598598 deliverReadyStream(t, stream)
599599 awaitAttachedState(t, harness.sup)
600- written := harness.terminal.written()
600+ // The attached worker's palette query draws nothing; strip it.
601+ written := strings.ReplaceAll(harness.terminal.written(), paletteColorBatch, "")
601602 at := strings.Index(written, "\x1b[Hready")
602603 require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication")
603604 require.Equal(t, "\x1b[Hready", written[at:],
@@ -597,7 +597,8 @@ func TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput(t *tes
597 // not have driven a connecting repaint at the new geometry.597 // not have driven a connecting repaint at the new geometry.
598 deliverReadyStream(t, stream)598 deliverReadyStream(t, stream)
599 awaitAttachedState(t, harness.sup)599 awaitAttachedState(t, harness.sup)
600- written := harness.terminal.written()600+ // The attached worker's palette query draws nothing; strip it.
601+ written := strings.ReplaceAll(harness.terminal.written(), paletteColorBatch, "")
601 at := strings.Index(written, "\x1b[Hready")602 at := strings.Index(written, "\x1b[Hready")
602 require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication")603 require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication")
603 require.Equal(t, "\x1b[Hready", written[at:],604 require.Equal(t, "\x1b[Hready", written[at:],
M internal/usecase/client/supervisor.go +3 -0
@@ -399,6 +399,9 @@ type Supervisor struct {
399399 // clientID is the stable client identity carried in every Hello this
400400 // supervisor sends, across reconnects and attachments.
401401 clientID [16]byte
402+ // theme retains the terminal-reported colors across attachments, so a
403+ // replacement attachment restores them before its own palette query ends.
404+ theme terminalThemeState
402405 // readySub is the adopted connection's subscription while the ready phase
403406 // runs, so the picker overlay over a live attachment keeps folding broker
404407 // publications. It is only touched from the run goroutine.
@@ -399,6 +399,9 @@ type Supervisor struct {
399 // clientID is the stable client identity carried in every Hello this399 // clientID is the stable client identity carried in every Hello this
400 // supervisor sends, across reconnects and attachments.400 // supervisor sends, across reconnects and attachments.
401 clientID [16]byte401 clientID [16]byte
402+ // theme retains the terminal-reported colors across attachments, so a
403+ // replacement attachment restores them before its own palette query ends.
404+ theme terminalThemeState
402 // readySub is the adopted connection's subscription while the ready phase405 // readySub is the adopted connection's subscription while the ready phase
403 // runs, so the picker overlay over a live attachment keeps folding broker406 // runs, so the picker overlay over a live attachment keeps folding broker
404 // publications. It is only touched from the run goroutine.407 // publications. It is only touched from the run goroutine.
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -307,6 +307,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
307307 SessionEnvironment: sessionEnv,
308308 BeforeAttached: beforeAttached,
309309 Clock: s.cfg.Clock,
310+ Theme: &s.theme,
310311 })
311312 if err != nil {
312313 return nil, err
@@ -307,6 +307,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
307 SessionEnvironment: sessionEnv,307 SessionEnvironment: sessionEnv,
308 BeforeAttached: beforeAttached,308 BeforeAttached: beforeAttached,
309 Clock: s.cfg.Clock,309 Clock: s.cfg.Clock,
310+ Theme: &s.theme,
310 })311 })
311 if err != nil {312 if err != nil {
312 return nil, err313 return nil, err
M internal/usecase/client/supervisor_attach_test.go +3 -1
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "io"
8+ "strings"
89 "sync"
910 "testing"
1011 "time"
@@ -894,7 +895,8 @@ func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) {
894895 require.False(t, picker.owns(), "the picker released input for the attachment")
895896 deliverReadyStream(t, admitted)
896897 awaitAttachedState(t, harness.sup)
897- require.Equal(t, "\x1b[Hready", harness.terminal.written())
898+ // The attached worker's palette query draws nothing; strip it.
899+ require.Equal(t, "\x1b[Hready", strings.ReplaceAll(harness.terminal.written(), paletteColorBatch, ""))
898900 // Two transactions commit on this attachment: the initial frame under the
899901 // pre-attach Connecting presentation, then the committed Attached
900902 // presentation with no bytes of its own.
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8+ "strings"
8 "sync"9 "sync"
9 "testing"10 "testing"
10 "time"11 "time"
@@ -894,7 +895,8 @@ func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) {
894 require.False(t, picker.owns(), "the picker released input for the attachment")895 require.False(t, picker.owns(), "the picker released input for the attachment")
895 deliverReadyStream(t, admitted)896 deliverReadyStream(t, admitted)
896 awaitAttachedState(t, harness.sup)897 awaitAttachedState(t, harness.sup)
897- require.Equal(t, "\x1b[Hready", harness.terminal.written())898+ // The attached worker's palette query draws nothing; strip it.
899+ require.Equal(t, "\x1b[Hready", strings.ReplaceAll(harness.terminal.written(), paletteColorBatch, ""))
898 // Two transactions commit on this attachment: the initial frame under the900 // Two transactions commit on this attachment: the initial frame under the
899 // pre-attach Connecting presentation, then the committed Attached901 // pre-attach Connecting presentation, then the committed Attached
900 // presentation with no bytes of its own.902 // presentation with no bytes of its own.
M internal/usecase/theme/scanner_batch_test.go +4 -4
@@ -26,18 +26,18 @@ func TestScannerBatchBoundaryPreservesBytesAndReuse(t *testing.T) {
2626 scan := func(data string) {
2727 scanner.Scan([]byte(data), func(int, renderer.RGB) { callbacks++ }, func(int, renderer.RGB) { callbacks++ }, func(bool) { callbacks++ }, func(data []byte) { out.Write(data) })
2828 }
29- require.False(t, scanner.Pending())
29+ require.False(t, (len(scanner.pending) != 0))
3030 scan(test.input)
31- require.Equal(t, test.pending, scanner.Pending())
31+ require.Equal(t, test.pending, (len(scanner.pending) != 0))
3232 scanner.EndBatch(func(data []byte) { out.Write(data) })
33- require.False(t, scanner.Pending())
33+ require.False(t, (len(scanner.pending) != 0))
3434 require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies")
3535 require.Zero(t, callbacks)
3636 scanner.EndBatch(func(data []byte) { out.Write(data) })
3737 require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes")
3838 scan("\x1b]10;#123456\a")
3939 require.Equal(t, 1, callbacks, "the reusable scanner still handles normal terminal responses")
40- require.False(t, scanner.Pending())
40+ require.False(t, (len(scanner.pending) != 0))
4141 })
4242 }
4343 }
@@ -26,18 +26,18 @@ func TestScannerBatchBoundaryPreservesBytesAndReuse(t *testing.T) {
26 scan := func(data string) {26 scan := func(data string) {
27 scanner.Scan([]byte(data), func(int, renderer.RGB) { callbacks++ }, func(int, renderer.RGB) { callbacks++ }, func(bool) { callbacks++ }, func(data []byte) { out.Write(data) })27 scanner.Scan([]byte(data), func(int, renderer.RGB) { callbacks++ }, func(int, renderer.RGB) { callbacks++ }, func(bool) { callbacks++ }, func(data []byte) { out.Write(data) })
28 }28 }
29- require.False(t, scanner.Pending())29+ require.False(t, (len(scanner.pending) != 0))
30 scan(test.input)30 scan(test.input)
31- require.Equal(t, test.pending, scanner.Pending())31+ require.Equal(t, test.pending, (len(scanner.pending) != 0))
32 scanner.EndBatch(func(data []byte) { out.Write(data) })32 scanner.EndBatch(func(data []byte) { out.Write(data) })
33- require.False(t, scanner.Pending())33+ require.False(t, (len(scanner.pending) != 0))
34 require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies")34 require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies")
35 require.Zero(t, callbacks)35 require.Zero(t, callbacks)
36 scanner.EndBatch(func(data []byte) { out.Write(data) })36 scanner.EndBatch(func(data []byte) { out.Write(data) })
37 require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes")37 require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes")
38 scan("\x1b]10;#123456\a")38 scan("\x1b]10;#123456\a")
39 require.Equal(t, 1, callbacks, "the reusable scanner still handles normal terminal responses")39 require.Equal(t, 1, callbacks, "the reusable scanner still handles normal terminal responses")
40- require.False(t, scanner.Pending())40+ require.False(t, (len(scanner.pending) != 0))
41 })41 })
42 }42 }
43 }43 }
M internal/usecase/theme/theme.go +0 -4
@@ -126,10 +126,6 @@ type Scanner struct {
126126 pending []byte
127127 }
128128
129-// Pending reports undecided terminal-response bytes at the input owner's
130-// admission boundary. Like Scan, it is called only by that owner.
131-func (s *Scanner) Pending() bool { return len(s.pending) != 0 }
132-
133129 // EndBatch forwards an admitted batch's undecided suffix as ordinary input.
134130 // It neither invents a terminal response nor closes the reusable scanner.
135131 func (s *Scanner) EndBatch(onBytes func([]byte)) {
@@ -126,10 +126,6 @@ type Scanner struct {
126 pending []byte126 pending []byte
127 }127 }
128 128
129-// Pending reports undecided terminal-response bytes at the input owner's
130-// admission boundary. Like Scan, it is called only by that owner.
131-func (s *Scanner) Pending() bool { return len(s.pending) != 0 }
132-
133 // EndBatch forwards an admitted batch's undecided suffix as ordinary input.129 // EndBatch forwards an admitted batch's undecided suffix as ordinary input.
134 // It neither invents a terminal response nor closes the reusable scanner.130 // It neither invents a terminal response nor closes the reusable scanner.
135 func (s *Scanner) EndBatch(onBytes func([]byte)) {131 func (s *Scanner) EndBatch(onBytes func([]byte)) {

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