1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bnema/vev/internal/adapters/clipboard"
14 "github.com/bnema/vev/internal/adapters/clock"
15 "github.com/bnema/vev/internal/adapters/term"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/terminalcap"
18 "github.com/bnema/vev/internal/platform"
19 "github.com/bnema/vev/internal/ports"
20 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/protocol/catalogue"
22 "github.com/bnema/vev/internal/usecase/client"
23)
24
25// Common broker client composition.
26//
27// runBrokerClient is the single terminal composition every frontend promotes:
28// the sandbox terminal harness, the sandbox UI-driver harness, and the ordinary
29// production terminal attach all build one brokerClientConfig and delegate
30// here. It constructs exactly one client.Picker and exactly one
31// client.Supervisor and hands the whole run to Supervisor.Run, so no frontend
32// keeps a parallel picker, supervisor, attachment stream, or daemon dialer.
33//
34// The composition owns only what the supervisor cannot: the connector (or the
35// production connect-or-spawn behind it), the terminal, the clock, the optional
36// UI publication/action seam, the one-shot initial navigation intent (or the
37// single-shot resolver that translates a CLI target into it), the copied
38// attachment environment, the optional lifecycle-action channel, and the
39// presentation callbacks. It never opens a stream, resolves a key, or drives
40// picker input itself.
41
42// brokerClientConfig is the common client configuration shared by every
43// frontend. Connector, Terminal, and Clock are required (a nil Clock falls back
44// to the process clock); the remaining fields are optional and keep the
45// supervisor defaults when they are left zero.
46type brokerClientConfig struct {
47 Logger *slog.Logger
48 Connector ports.BrokerConnector
49 Terminal ports.Terminal
50 Clock ports.Clock
51 Clipboard ports.ClipboardReader
52 UI *client.UI
53 // InitialNavigation is the one-shot intent decided before the connection.
54 // Its zero value is InitialNavigationPicker, which opens nothing.
55 InitialNavigation client.InitialNavigation
56 // ResolveInitialNavigation translates a composition-owned CLI target (an
57 // attach name or a remote endpoint) into an exact navigation identity
58 // against the first committed broker publication. It is mutually exclusive
59 // with a non-zero InitialNavigation.
60 ResolveInitialNavigation client.InitialNavigationResolver
61 // AttachmentEnvironment is the copied process context of each attachment
62 // Hello. It is never read from the broker process.
63 AttachmentEnvironment client.AttachmentEnvironment
64 // SessionEnvironment is the process snapshot for session creation. The
65 // production frontends capture one LocalCLI snapshot before NewSupervisor;
66 // offline and test compositions pass an explicit valid value (LocalCLI
67 // with empty Env/Cwd unless the test asserts env). A zero value falls
68 // back to the production LocalCLI snapshot inside runBrokerClient so the
69 // ordinary terminal path keeps no second capture.
70 SessionEnvironment client.SessionEnvironment
71 // LifecycleActions carries explicit attachment lifecycle requests. A nil
72 // channel disables external lifecycle actions.
73 LifecycleActions <-chan client.AttachmentLifecycleAction
74 // OnState observes every supervisor state. Optional.
75 OnState func(client.State)
76 // OnFailure surfaces one typed attachment or connectivity failure without
77 // ending the run. Optional.
78 OnFailure func(error)
79 // OnLifecycle reports typed, bounded lifecycle transitions. Optional.
80 OnLifecycle func(client.LifecycleNotice)
81}
82
83// brokerClientPresentation is the single terminal writer of one broker client
84// run. Rendering the picker and reporting a bounded failure both go through it,
85// so a failure notice can never interleave bytes with a picker frame.
86type brokerClientPresentation struct {
87 terminal ports.Terminal
88 picker *client.Picker
89 // ui is the run's UI owner. When set, the picker frame is written inside the
90 // terminal's UI output transaction under the Picker presentation, exactly like
91 // daemon output, so the committed snapshot and the painted bytes describe the
92 // same unattached state. Optional: a test may paint without an observation
93 // channel.
94 ui *client.UI
95 onState func(client.State)
96 clock ports.Clock
97 spinner ports.Timer
98 frame int
99
100 mu sync.Mutex
101}
102
103// Render observes the state and paints the picker frame and notice while the
104// shared client.PickerPresentation fence admits the presentation. That is
105// exactly PresentPicker: an admitted attachment foreground owns the same
106// terminal writer before its attached marker and initial publication, and a
107// terminating process is leaving the terminal, so neither is ever painted over.
108// Picker is therefore never painted while the supervisor presents Connecting.
109//
110// The frame is committed through the terminal's UI output transaction under the
111// Picker presentation, so the published context and the painted bytes are one
112// coherent unattached state; Picker carries the run's handle with no session
113// identity and no actionable generation. An unavailable observation channel
114// leaves the frame written and flushed, exactly like the attachment foreground.
115//
116// ResizeEvents has exactly one consumer for the whole supervisor run (its
117// attachment geometry collector); rendering samples Geometry here at render
118// time instead of competing for that single event channel, so a repaint always
119// observes the latest size.
120func (p *brokerClientPresentation) Render(state client.State) {
121 if p == nil {
122 return
123 }
124 if p.onState != nil {
125 p.onState(state)
126 }
127 if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) {
128 return
129 }
130 p.mu.Lock()
131 defer p.mu.Unlock()
132 geometry, err := p.terminal.Geometry()
133 if err != nil {
134 return
135 }
136 var frame, notice []byte
137 if client.PickerPresentation(state) {
138 frame = p.picker.Render(geometry.Size)
139 notice = p.picker.RenderNotice(geometry.Size)
140 } else {
141 frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…")
142 }
143 if state.Connectivity == client.ConnectivityRetryWait {
144 notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…")
145 }
146 if len(frame) == 0 && len(notice) == 0 {
147 return
148 }
149 writer := p.terminal.Out()
150 if writer == nil {
151 return
152 }
153 transaction, _ := p.terminal.(ports.UIOutputTransaction)
154 context := p.pickerContext()
155 switch state.Presentation {
156 case client.PresentConnecting:
157 context.Status = ports.UIStatusConnecting
158 case client.PresentAttachedPicker:
159 // The picker is composed over a live attachment: the attachment stays
160 // the actionable generation, so the frame keeps its attached context.
161 if attached, ok := p.ui.OverlayContext(); ok {
162 context = attached
163 }
164 }
165 if transaction != nil {
166 transaction.BeginOutput(context)
167 if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
168 transaction.EndOutput(false)
169 return
170 }
171 }
172 success := true
173 if len(frame) != 0 {
174 if _, writeErr := writer.Write(frame); writeErr != nil {
175 success = false
176 }
177 }
178 if success && len(notice) != 0 {
179 if _, writeErr := writer.Write(notice); writeErr != nil {
180 success = false
181 }
182 }
183 if success && p.terminal.Flush() != nil {
184 success = false
185 }
186 if transaction != nil {
187 transaction.EndOutput(success)
188 }
189}
190
191func (p *brokerClientPresentation) Spinner() <-chan time.Time {
192 p.mu.Lock()
193 defer p.mu.Unlock()
194 if p.spinner == nil {
195 p.spinner = p.clock.NewTimer(120 * time.Millisecond)
196 }
197 return p.spinner.C()
198}
199
200func (p *brokerClientPresentation) AdvanceSpinner(state client.State) {
201 p.mu.Lock()
202 p.frame++
203 if p.spinner != nil {
204 p.spinner.Reset(120 * time.Millisecond)
205 }
206 p.mu.Unlock()
207 p.Render(state)
208}
209
210func (p *brokerClientPresentation) StopSpinner() {
211 p.mu.Lock()
212 if p.spinner != nil {
213 p.spinner.Stop()
214 }
215 p.mu.Unlock()
216}
217
218// pickerContext is the Picker presentation of this run: the stable UI handle and
219// the status alone. It is the shape every unattached publication uses, so a
220// capture of the painted picker never shows an old attachment's session
221// identity or generation.
222func (p *brokerClientPresentation) pickerContext() ports.UIContext {
223 context := ports.UIContext{Status: ports.UIStatusPicker}
224 if p.ui != nil {
225 context.AttachmentHandle = p.ui.Handle()
226 }
227 return context
228}
229
230// Failure reports one bounded attachment or connectivity failure on the same
231// terminal writer. The supervisor already reports a typed, bounded lifecycle
232// notice through the picker, so this is the complementary diagnostic: one
233// bounded line under the shared writer lock, never a raw cause chain and never
234// a competing terminal owner.
235func (p *brokerClientPresentation) Failure(state client.State, err error) {
236 if p == nil || err == nil {
237 return
238 }
239 // A retryable failure is already shown by the repainted retry notice. A
240 // raw line here would scroll the painted screen on every attempt and
241 // leave a trail of stale notices behind it.
242 if state.Connectivity == client.ConnectivityRetryWait {
243 return
244 }
245 text := err.Error()
246 if len(text) > ports.BrokerMaxErrorBytes {
247 text = text[:ports.BrokerMaxErrorBytes]
248 }
249 p.mu.Lock()
250 defer p.mu.Unlock()
251 writer := p.terminal.Out()
252 if writer == nil {
253 return
254 }
255 if _, writeErr := writer.Write([]byte("\r\nvev: " + text + "\r\n")); writeErr != nil {
256 return
257 }
258 _ = p.terminal.Flush()
259}
260
261// runBrokerClient builds exactly one picker and one supervisor over cfg and
262// delegates the whole run to the supervisor. It returns the supervisor's
263// terminal cause: a clean terminal EOF, an explicit exit, or the run's
264// cancellation, joined with any failure the supervisor classified as terminal.
265func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
266 if cfg.Terminal == nil {
267 return errors.New("broker client requires a terminal")
268 }
269 clk := cfg.Clock
270 if clk == nil {
271 clk = clock.New()
272 }
273 sessionEnv := cfg.SessionEnvironment
274 if sessionEnv.Provenance == client.SessionEnvironmentUnspecified {
275 // Production fallback: capture one LocalCLI process snapshot before
276 // NewSupervisor so ordinary terminal attach keeps no second capture.
277 sessionEnv = terminalSessionEnvironment()
278 }
279 attachmentEnv := cfg.AttachmentEnvironment
280 if sessionEnv.Cwd != "" {
281 attachmentEnv.Cwd = sessionEnv.Cwd
282 }
283 reader := cfg.Clipboard
284 if reader == nil {
285 reader = clipboard.New()
286 }
287 picker := client.NewPicker(clk, 0, attachmentEnv.Color)
288 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
289 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
290 Logger: cfg.Logger,
291 Connector: cfg.Connector,
292 Terminal: cfg.Terminal,
293 Clock: clk,
294 Clipboard: reader,
295 UI: cfg.UI,
296 Picker: picker,
297 InitialNavigation: cfg.InitialNavigation,
298 ResolveInitialNavigation: cfg.ResolveInitialNavigation,
299 AttachmentEnvironment: attachmentEnv,
300 SessionEnvironment: sessionEnv,
301 LifecycleActions: cfg.LifecycleActions,
302 Render: presentation.Render,
303 Notify: func(state client.State, err error) {
304 if cfg.OnFailure != nil {
305 cfg.OnFailure(err)
306 return
307 }
308 presentation.Failure(state, err)
309 },
310 NotifyLifecycle: func(notice client.LifecycleNotice) {
311 if cfg.OnLifecycle != nil {
312 cfg.OnLifecycle(notice)
313 }
314 if notice.Kind == client.LifecycleNoticeBrokerLost {
315 picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"})
316 }
317 if notice.Kind == client.LifecycleNoticeBrokerReconnected {
318 picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"})
319 }
320 },
321 Spinner: presentation,
322 })
323 if err != nil {
324 return err
325 }
326 return supervisor.Run(ctx)
327}
328
329// localEphemeralNavigation is the no-argument product intent: create one
330// ephemeral session on the broker-owned local daemon. It is decided before the
331// connection, so it carries no epoch and needs no resolver.
332func localEphemeralNavigation() client.InitialNavigation {
333 return client.InitialNavigation{
334 Kind: client.InitialNavigationCreateEphemeral,
335 Destination: ports.BrokerEndpointFence{Local: true},
336 }
337}
338
339// errTerminalNavigationUnresolved reports that a CLI target names a host the
340// broker catalogue does not carry. It is a local refusal:
341// no destination was dialed, so the supervisor surfaces it as a bounded
342// selection-unavailable notice and returns to the picker instead of attaching a
343// fallback or creating implicitly.
344var errTerminalNavigationUnresolved = errors.New("attach host is not in the broker catalogue")
345
346// terminalBrokerNavigation translates the parsed terminal CLI intent into the
347// closed initial-navigation union. An intent that is fully determined before
348// the connection (local creation, local named attach) is a value; a remote
349// target is a single-shot resolver that runs once against the first committed
350// snapshot to fence the host registration.
351//
352// Named attach never consults the catalogue for session existence: the daemon
353// restores or refuses the name. A remote destination is only ever the complete
354// registration a snapshot carries; an unknown host is a refusal.
355func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {
356 if remoteTarget == "" {
357 switch intent {
358 case protocol.IntentEphemeral:
359 return localEphemeralNavigation(), nil, nil
360 case protocol.IntentNew:
361 if err := domain.ValidateSessionName(name); err != nil {
362 return client.InitialNavigation{}, nil, err
363 }
364 return client.InitialNavigation{
365 Kind: client.InitialNavigationCreateNamed,
366 Destination: ports.BrokerEndpointFence{Local: true},
367 Name: name,
368 }, nil, nil
369 case protocol.IntentAttach:
370 if err := domain.ValidateSessionName(name); err != nil {
371 return client.InitialNavigation{}, nil, err
372 }
373 // The daemon resolves the name, so a catalogue that has not
374 // observed the session yet cannot refuse it.
375 return client.InitialNavigation{
376 Kind: client.InitialNavigationAttachNamed,
377 Destination: ports.BrokerEndpointFence{Local: true},
378 Name: name,
379 }, nil, nil
380 default:
381 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
382 }
383 }
384 if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
385 return client.InitialNavigation{}, nil, err
386 }
387 switch intent {
388 case protocol.IntentEphemeral:
389 // `attach user@host` without a session keeps its product behavior: a
390 // fresh ephemeral session on that remote daemon.
391 return client.InitialNavigation{}, remoteEphemeralCreateResolver(remoteTarget), nil
392 case protocol.IntentNew:
393 if err := domain.ValidateSessionName(name); err != nil {
394 return client.InitialNavigation{}, nil, err
395 }
396 return client.InitialNavigation{}, remoteNamedCreateResolver(remoteTarget, name), nil
397 case protocol.IntentAttach:
398 if err := domain.ValidateSessionName(name); err != nil {
399 return client.InitialNavigation{}, nil, err
400 }
401 return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil
402 default:
403 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
404 }
405}
406
407// remoteNamedAttachResolver binds a remote named attach to the endpoint's
408// committed registration; the remote daemon resolves the name.
409func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver {
410 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
411 observation, ok := snapshot.Find(endpoint)
412 if !ok {
413 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
414 }
415 return client.InitialNavigation{
416 Kind: client.InitialNavigationAttachNamed,
417 Epoch: snapshot.Epoch,
418 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
419 Name: name,
420 }, nil
421 }
422}
423
424// remoteExactAttachResolver resolves one remote session name to its exact
425// lifecycle under the endpoint's committed registration. The destination is the
426// complete observed registration (endpoint, incarnation, and generation), never
427// a bare hostname and never an invented daemon identity; an endpoint the broker
428// does not carry is refused rather than dialed.
429func remoteExactAttachResolver(endpoint, name string) client.InitialNavigationResolver {
430 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
431 observation, ok := snapshot.Find(endpoint)
432 if !ok {
433 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
434 }
435 target, ok := brokerObservationExactTarget(observation, name)
436 if !ok {
437 return client.InitialNavigation{}, fmt.Errorf("%w: %q on %q", errTerminalNavigationUnresolved, name, endpoint)
438 }
439 return client.InitialNavigation{
440 Kind: client.InitialNavigationAttachExact,
441 Epoch: snapshot.Epoch,
442 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
443 Target: target,
444 }, nil
445 }
446}
447
448func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationResolver {
449 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
450 observation, ok := snapshot.Find(endpoint)
451 if !ok {
452 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
453 }
454 return client.InitialNavigation{
455 Kind: client.InitialNavigationCreateNamed,
456 Epoch: snapshot.Epoch,
457 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
458 Name: name,
459 }, nil
460 }
461}
462
463// remoteAttachOrCreateResolver resolves one remote session name to an exact
464// attach when the endpoint's committed observation carries it, and to a named
465// creation fenced to the same registration once an inventory observed at or
466// after requestedAt proves it absent. Before such an observation it asks the
467// supervisor to request one and resolve again, with the creation as the
468// fallback should the host stay unobserved. An endpoint the broker does not
469// carry is refused rather than dialed.
470func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time) client.InitialNavigationResolver {
471 attach := remoteExactAttachResolver(endpoint, name)
472 create := remoteNamedCreateResolver(endpoint, name)
473 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
474 observation, ok := snapshot.Find(endpoint)
475 if !ok {
476 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
477 }
478 if _, exists := brokerObservationExactTarget(observation, name); exists {
479 return attach(snapshot)
480 }
481 if !observation.InventoryKnown || observation.LastSuccess.Before(requestedAt) {
482 // Should the host stay unobserved, creating is still the only
483 // resolvable intent; the remote daemon refuses a live name.
484 fallback, err := create(snapshot)
485 if err != nil {
486 fallback = client.InitialNavigation{}
487 }
488 return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint, Fallback: fallback}
489 }
490 return create(snapshot)
491 }
492}
493
494// remoteEphemeralCreateResolver resolves `attach user@host` (no session) into a
495// remote ephemeral creation fenced to the endpoint's committed registration and
496// the epoch of the snapshot that resolved it.
497func remoteEphemeralCreateResolver(endpoint string) client.InitialNavigationResolver {
498 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
499 observation, ok := snapshot.Find(endpoint)
500 if !ok {
501 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
502 }
503 return client.InitialNavigation{
504 Kind: client.InitialNavigationCreateEphemeral,
505 Epoch: snapshot.Epoch,
506 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
507 }, nil
508 }
509}
510
511// localBrokerObservation returns the single local daemon observation of one
512// publication.
513func localBrokerObservation(snapshot ports.BrokerSnapshot) (ports.BrokerDaemonObservation, bool) {
514 for _, observation := range snapshot.Daemons {
515 if observation.Local {
516 return observation, true
517 }
518 }
519 return ports.BrokerDaemonObservation{}, false
520}
521
522// brokerObservationExactTarget resolves one session name to the exact
523// lifecycle/name pair the observation publishes. A broken session is refused:
524// its identity is known but not attachable, so an explicit attempt would only be
525// a guaranteed refusal.
526func brokerObservationExactTarget(observation ports.BrokerDaemonObservation, name string) (protocol.ExactSessionTarget, bool) {
527 for _, session := range observation.Sessions {
528 if session.Name != name || session.State == catalogue.RemoteCatalogSessionBroken {
529 continue
530 }
531 target := protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
532 if err := target.Validate(); err != nil {
533 return protocol.ExactSessionTarget{}, false
534 }
535 return target, true
536 }
537 return protocol.ExactSessionTarget{}, false
538}
539
540// productionBrokerConnector is every frontend's production broker connector. It
541// performs no I/O at construction: Connect connect-or-spawns the per-user
542// broker on demand, so a broker that is absent or incompatible is an ordinary
543// attempt failure under the supervisor's own backoff instead of a fatal startup
544// error. It never dials a daemon and never selects a transport.
545//
546// It is stateless beyond its connect function: the supervisor owns and closes
547// every service it adopts, so the connector keeps no eager connection and no
548// close path of its own.
549type productionBrokerConnector struct {
550 connect func(context.Context) (ports.BrokerService, error)
551}
552
553func localBrokerOperationRoute(snapshot ports.BrokerSnapshot) client.BrokerOperationRoute {
554 return client.BrokerOperationRoute{
555 Epoch: snapshot.Epoch,
556 Destination: ports.BrokerEndpointFence{Local: true},
557 }
558}
559
560var _ ports.BrokerConnector = (*productionBrokerConnector)(nil)
561
562// newProductionBrokerConnector returns a lazy production connector. The
563// connect function is a composition seam for tests; production uses the shared
564// connect-or-spawn.
565func newProductionBrokerConnector() ports.BrokerConnector {
566 return &productionBrokerConnector{connect: func(ctx context.Context) (ports.BrokerService, error) {
567 return connectProductionClientBroker(ctx)
568 }}
569}
570
571// Connect connect-or-spawns one broker connection for the calling attempt,
572// including the first one, so every attempt is under the supervisor's retry
573// cadence.
574func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
575 if c == nil || c.connect == nil {
576 return nil, errors.New("broker connector is not configured")
577 }
578 service, err := c.connect(ctx)
579 if err != nil {
580 return nil, err
581 }
582 return service, nil
583}
584
585// terminalBrokerSeams are the optional observation callbacks of one terminal
586// run. Production supplies none: runBrokerClient reports a failure on the
587// terminal itself through the same writer the picker paints with. The terminal
588// composition tests substitute every callback to observe the same run's states,
589// lifecycle notices, and failures.
590type terminalBrokerSeams struct {
591 OnState func(client.State)
592 OnLifecycle func(client.LifecycleNotice)
593 OnFailure func(error)
594}
595
596// terminalBrokerCallbacks is the composition-root seam producing one terminal
597// run's callbacks.
598var terminalBrokerCallbacks = func() terminalBrokerSeams { return terminalBrokerSeams{} }
599
600// Composition-root seams for the terminal client. Production supplies the
601// production connect-or-spawn, the physical terminal, and the detached local
602// creation; tests substitute them so the terminal composition is exercised
603// without a production broker process, a real controlling terminal, or a real
604// daemon.
605var (
606 connectProductionClientBroker = connectProductionBroker
607 terminalForAttach = func() ports.Terminal { return term.New() }
608 createDetachedTerminalSession = createDetachedLocalSession
609)
610
611// terminalAttachmentEnvironment copies the composition-owned process context an
612// attachment Hello carries. The supervisor and the broker never inspect the
613// process environment themselves.
614func terminalAttachmentEnvironment() client.AttachmentEnvironment {
615 return client.AttachmentEnvironment{
616 TermEnv: os.Getenv("TERM"),
617 Cwd: currentWorkingDirectory(),
618 Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
619 PID: uint32(os.Getpid()),
620 }
621}
622
623// detectTerminalColor derives the color capability from the process
624// environment. Explicit TERM/COLORTERM values override env entries.
625func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
626 detectionEnv := make([]string, 0, len(env)+2)
627 detectionEnv = append(detectionEnv, env...)
628 detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
629 return terminalcap.Detect(detectionEnv).Color
630}
631
632// envColors is the client-side color-mode override. It wins over the
633// terminal.colors config key and is read by each client process; the daemon
634// never sees it except as the Forced Hello.Color it produces.
635const envColors = "VEV_COLORS"
636
637// virtualTerminalColor is the color capability declared by clients whose
638// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
639// headless UI driver) rather than the gateway process' controlling terminal.
640// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
641var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
642
643// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
644// client that renders into an RGB-capable virtual terminal: it declares forced
645// truecolor instead of detecting from the gateway process environment.
646func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
647 env := terminalAttachmentEnvironment()
648 env.Color = virtualTerminalColor
649 return env
650}
651
652// resolveColorOverride applies the explicit color override on top of the
653// detected capability: a valid VEV_COLORS wins over terminal.colors, and
654// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
655// config value still applies. An invalid VEV_COLORS is reported through log
656// and ignored. A forced result carries SourceForced.
657func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
658 if strings.TrimSpace(envValue) != "" {
659 mode, auto, err := terminalcap.ParseColorMode(envValue)
660 switch {
661 case err != nil:
662 if log != nil {
663 log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
664 }
665 case auto:
666 return detected
667 default:
668 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
669 }
670 }
671 if cfg.ColorsSet {
672 return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
673 }
674 return detected
675}
676
677// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
678// client whose terminal is the user's real one: the detected color capability
679// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
680// both the picker and every Hello.
681func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
682 env := terminalAttachmentEnvironment()
683 env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
684 return env
685}
686
687// configuredTerminalAttachmentEnvironment loads the user's config and returns
688// userTerminalAttachmentEnvironment for it.
689func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690 return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
691}
692
693// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
694// for a client that owns a real outer terminal: it probes that terminal once
695// and applies the user's keyboard setting. Virtual terminals never probe.
696func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
697 cfg := loadConfigOrDefaults(log, platform.ConfigPath())
698 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
699 env.ProbeTerminal = true
700 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
701 return env
702}
703
704// terminalSessionEnvironment captures one LocalCLI process snapshot for
705// session creation before NewSupervisor. The supervisor remaps each remote
706// request to Remote, so the base provenance stays LocalCLI without branching
707// in the app.
708func terminalSessionEnvironment() client.SessionEnvironment {
709 return client.SessionEnvironment{
710 Provenance: client.SessionEnvironmentLocalCLI,
711 Env: append([]string(nil), os.Environ()...),
712 Cwd: currentWorkingDirectory(),
713 }
714}
715
716func currentWorkingDirectory() string {
717 cwd, err := os.Getwd()
718 if err != nil {
719 return ""
720 }
721 return cwd
722}