v0.5.0 c37833b

broker_client.go RawHistoryFolder

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

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close