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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 68 | 68 | // BeforeAttached runs after the initial frame commits but before attached | |
| 69 | 69 | // presentation becomes observable. A failure keeps the run unattached. | |
| 70 | 70 | 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. | |
| 73 | 74 | 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 | 81 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -287,9 +292,29 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 287 | 292 | } | |
| 288 | 293 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 289 | 294 | 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 | + | } | |
| 291 | 300 | for { | |
| 292 | 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 | 318 | case <-repaint: | |
| 294 | 319 | // An overlay released the terminal: the picker box is still on screen | |
| 295 | 320 | // 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 | 388 | fg.PreserveInput(event.input.Data) | |
| 364 | 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 | 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 | 409 | consumed, err := picker.consumeInput(ctx, state, *event.input) | |
| 368 | 410 | if err != nil { | |
| 369 | 411 | fg.PreserveInput(event.input.Data) | |
| @@ -381,8 +423,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 381 | 423 | } | |
| 382 | 424 | continue | |
| 383 | 425 | } | |
| 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...)} | |
| 386 | 427 | } | |
| 387 | 428 | if resize, ok := event.message.(protocol.Resize); ok { | |
| 388 | 429 | 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 attached | 68 | // 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() error | 70 | BeforeAttached func() error | ||
| 71 | - | // Clock bounds the move picker's lone-escape window. A nil clock uses the | 71 | + | // 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.Clock | 74 | 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 logical | 81 | // 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 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 | + | } | |||
| 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 screen | 319 | // An overlay released the terminal: the picker box is still on screen | ||
| 295 | // and suppressed frames were never written, so ask the daemon for an | 320 | // 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 | continue | 424 | 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 | |||
| 849 | 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 | 893 | // attachmentRun is one in-flight worker generation owned by the supervisor. | |
| 853 | 894 | type attachmentRun struct { | |
| 854 | 895 | 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 *attachmentHost | 895 | host *attachmentHost | ||
M internal/usecase/client/palette_generation.go +30 -42
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sync/atomic" | |
| 4 | + | "sync" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | 7 | renderer "github.com/bnema/vev-vt" | |
| @@ -54,28 +54,43 @@ const ( | |||
| 54 | 54 | paletteEventBackground | |
| 55 | 55 | paletteEventPalette | |
| 56 | 56 | paletteEventScheme | |
| 57 | - | paletteEventUIBatchEnd | |
| 58 | 57 | ) | |
| 59 | 58 | ||
| 60 | 59 | 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 | |
| 68 | 65 | } | |
| 69 | 66 | ||
| 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 | |
| 73 | 87 | } | |
| 88 | + | return uint8(slot), true | |
| 74 | 89 | } | |
| 75 | 90 | ||
| 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. | |
| 79 | 94 | type paletteGenerationActionKind uint8 | |
| 80 | 95 | ||
| 81 | 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 | 262 | // paletteMarkerScanner consumes only complete DECRQM 2031 replies and retains | |
| 266 | 263 | // possible prefixes across reads, forwarding all ordinary input exactly once. | |
| 267 | 264 | type paletteMarkerScanner struct{ pending []byte } | |
| @@ -318,13 +315,4 @@ func (s *paletteMarkerScanner) flush(onBytes func([]byte)) { | |||
| 318 | 315 | 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 | 318 | func (s *paletteMarkerScanner) hasPendingPrefix() bool { return len(s.pending) != 0 } | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package client | 1 | 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 | paletteEventBackground | 54 | paletteEventBackground | ||
| 55 | paletteEventPalette | 55 | paletteEventPalette | ||
| 56 | paletteEventScheme | 56 | paletteEventScheme | ||
| 57 | - | paletteEventUIBatchEnd | |||
| 58 | ) | 57 | ) | ||
| 59 | 58 | ||||
| 60 | type paletteGenerationEvent struct { | 59 | type paletteGenerationEvent struct { | ||
| 61 | - | id paletteGenerationID | 60 | + | id paletteGenerationID |
| 62 | - | kind paletteGenerationEventKind | 61 | + | kind paletteGenerationEventKind |
| 63 | - | rgb renderer.RGB | 62 | + | rgb renderer.RGB |
| 64 | - | slot uint8 | 63 | + | slot uint8 |
| 65 | - | light bool | 64 | + | 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 is | 91 | + | // paletteGenerationAction is deliberately declarative. The attached loop is |
| 77 | - | // the sole terminal writer and protocol sender; timer bridges only enqueue a | 92 | + | // 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 uint8 | 94 | 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 retains | 262 | // 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 = nil | 315 | 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) | |||
| 246 | 246 | func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) { | |
| 247 | 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 } | |
| @@ -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) { | |||
| 44 | 44 | require.False(t, coalescer.Idle()) | |
| 45 | 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 | + | } | |
| @@ -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 { | |||
| 158 | 158 | 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 | 161 | // EndBatch flushes only ordinary undecided bytes after a validated automated | |
| 170 | 162 | // batch. An open paste is never terminated here; admission and input validation | |
| 171 | 163 | // must prevent automation from entering or inheriting one. | |
| @@ -158,14 +158,6 @@ func (c *pasteCoalescer) Buffering() bool { | |||||
| 158 | return c.buffering || len(c.pending) > 0 | 158 | 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 automated | 161 | // EndBatch flushes only ordinary undecided bytes after a validated automated | ||
| 170 | // batch. An open paste is never terminated here; admission and input validation | 162 | // 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) { | |||
| 310 | 310 | name: "a lone escape cancels once its window elapses", | |
| 311 | 311 | keys: []string{"\x1b"}, | |
| 312 | 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 | 326 | var escape *supervisorTestTimer | |
| 314 | 327 | require.Eventually(t, func() bool { | |
| 315 | 328 | for _, timer := range harness.clock.snapshotTimers() { | |
| 316 | - | if timer.delay == pickerEscapeDeadline && !timer.stopped() { | |
| 329 | + | if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() { | |
| 317 | 330 | escape = timer | |
| 318 | 331 | return true | |
| 319 | 332 | } | |
| @@ -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 *supervisorTestTimer | 326 | 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 = timer | 330 | escape = timer | ||
| 318 | return true | 331 | return true | ||
| 319 | } | 332 | } | ||
M internal/usecase/client/resize_presentation_test.go +2 -1
| @@ -597,7 +597,8 @@ func TestSupervisorResizeDuringAttachmentSettlementPreservesSessionOutput(t *tes | |||
| 597 | 597 | // not have driven a connecting repaint at the new geometry. | |
| 598 | 598 | deliverReadyStream(t, stream) | |
| 599 | 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 | 602 | at := strings.Index(written, "\x1b[Hready") | |
| 602 | 603 | require.GreaterOrEqual(t, at, 0, "the admitted foreground committed its initial publication") | |
| 603 | 604 | 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 { | |||
| 399 | 399 | // clientID is the stable client identity carried in every Hello this | |
| 400 | 400 | // supervisor sends, across reconnects and attachments. | |
| 401 | 401 | 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 | 405 | // readySub is the adopted connection's subscription while the ready phase | |
| 403 | 406 | // runs, so the picker overlay over a live attachment keeps folding broker | |
| 404 | 407 | // 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 this | 399 | // 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]byte | 401 | 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 phase | 405 | // readySub is the adopted connection's subscription while the ready phase | ||
| 403 | // runs, so the picker overlay over a live attachment keeps folding broker | 406 | // 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, | |||
| 307 | 307 | SessionEnvironment: sessionEnv, | |
| 308 | 308 | BeforeAttached: beforeAttached, | |
| 309 | 309 | Clock: s.cfg.Clock, | |
| 310 | + | Theme: &s.theme, | |
| 310 | 311 | }) | |
| 311 | 312 | if err != nil { | |
| 312 | 313 | 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, err | 313 | return nil, err | ||
M internal/usecase/client/supervisor_attach_test.go +3 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | + | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| @@ -894,7 +895,8 @@ func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) { | |||
| 894 | 895 | require.False(t, picker.owns(), "the picker released input for the attachment") | |
| 895 | 896 | deliverReadyStream(t, admitted) | |
| 896 | 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 | 900 | // Two transactions commit on this attachment: the initial frame under the | |
| 899 | 901 | // pre-attach Connecting presentation, then the committed Attached | |
| 900 | 902 | // 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 the | 900 | // Two transactions commit on this attachment: the initial frame under the | ||
| 899 | // pre-attach Connecting presentation, then the committed Attached | 901 | // 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) { | |||
| 26 | 26 | scan := func(data string) { | |
| 27 | 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 | 30 | scan(test.input) | |
| 31 | - | require.Equal(t, test.pending, scanner.Pending()) | |
| 31 | + | require.Equal(t, test.pending, (len(scanner.pending) != 0)) | |
| 32 | 32 | scanner.EndBatch(func(data []byte) { out.Write(data) }) | |
| 33 | - | require.False(t, scanner.Pending()) | |
| 33 | + | require.False(t, (len(scanner.pending) != 0)) | |
| 34 | 34 | require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies") | |
| 35 | 35 | require.Zero(t, callbacks) | |
| 36 | 36 | scanner.EndBatch(func(data []byte) { out.Write(data) }) | |
| 37 | 37 | require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes") | |
| 38 | 38 | scan("\x1b]10;#123456\a") | |
| 39 | 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 | } | |
| @@ -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 { | |||
| 126 | 126 | 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 | 129 | // EndBatch forwards an admitted batch's undecided suffix as ordinary input. | |
| 134 | 130 | // It neither invents a terminal response nor closes the reusable scanner. | |
| 135 | 131 | func (s *Scanner) EndBatch(onBytes func([]byte)) { | |
| @@ -126,10 +126,6 @@ type Scanner struct { | |||||
| 126 | pending []byte | 126 | 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)) { | ||