render_pipeline.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"slices"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/layout"
12	"github.com/bnema/vev/internal/usecase/notices"
13	"github.com/bnema/vev/internal/usecase/palette"
14	"github.com/bnema/vev/internal/usecase/prompt"
15	themeui "github.com/bnema/vev/internal/usecase/theme"
16	"github.com/bnema/vev/internal/usecase/ui"
17	"github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// composeCacheInput is an attachment-local value snapshot. composeFrame never
21// retains or accesses the attachment that owns it.
22// renderStageHooks are optional observability hooks. capture and compose run
23// after their result is available, and emit after prepare plus transport success
24// (including a successful no-byte emission); those hooks run while sendMu is
25// held. Failed attempts count for capture/compose but never for emit.
26// handoffRebase marks the boundary immediately before a handoff acquires sendMu
27// to rebase attachment-owned output state, so it intentionally runs unlocked.
28type renderStageHooks struct {
29	capture       func()
30	compose       func()
31	emit          func()
32	handoffRebase func()
33}
34
35type composeCacheInput struct {
36	valid                   bool
37	frame                   renderer.Frame
38	layoutFingerprint       string
39	titleGenerations        map[layout.PaneID]uint64
40	damage                  []renderer.Damage
41	floatingVisible         bool
42	floatingFocused         bool
43	floatingGeneration      uint64
44	floatingGeometry        floatingGeometry
45	floatingTitleGeneration uint64
46	bars                    barCache
47	toastFootprints         []domain.Rect
48	theme                   themeui.Theme
49	styleGeneration         uint64
50	copyViewport            copyViewportState
51}
52
53type composedRenderFrame struct {
54	frame  renderer.Frame
55	damage []renderer.Damage
56	cursor cursorOut
57	cache  composeCacheInput
58	reset  bool
59}
60
61// composeFrame is pure with respect to daemon ownership: it consumes only the
62// capture, the last committed cache, and an independent attachment-owned
63// scratch cache. It returns a new cache without mutating committed.
64const inactivePaneForegroundDimming = 55
65
66// clearOutsideLayout blanks the content cells that the shared layout does not
67// cover. A window larger than the shared session geometry reuses its previous
68// frame, so without this the cells of a wider or taller earlier layout stay on
69// screen.
70func clearOutsideLayout(frame renderer.Frame, content, area domain.Rect) {
71	blank := renderer.BlankCell()
72	for y := content.Y; y < content.Y+content.Height; y++ {
73		start := area.Width
74		if y-content.Y >= area.Height {
75			start = 0
76		}
77		frame.FillRow(y, content.X+start, content.X+content.Width, blank)
78	}
79}
80
81func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
82	scratch := composeCacheInput{}
83	if len(scratchIn) > 0 {
84		scratch = scratchIn[0]
85	}
86	width, rows := state.layout.area.Width, state.layout.area.Height
87	if state.window.Valid() {
88		window := contentSize(state.window)
89		width, rows = window.Cols, window.Rows
90	}
91	if width <= 0 || rows < 0 {
92		return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
93	}
94	// Compose into the alternate attachment-owned buffer. The committed frame is
95	// copied before incremental drawing so prepare/send failures cannot change it.
96	contentY := 1
97	frameHeight := rows + tabChromeRows
98	canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
99	frame := scratch.frame
100	if !canReuseFrame {
101		frame = renderer.NewFrame(width, frameHeight)
102	}
103	if in.frame.Width == width && in.frame.Height == frameHeight {
104		if state.overlays.copyMode != nil {
105			// A structural copy preserves page-local style IDs. Re-interning every
106			// cell costs more while scrolling. Copying into the spare page keeps
107			// committed state isolated from failed publications without a new page.
108			frame.CopyFrom(in.frame)
109		} else {
110			for y := 0; y < frame.Height; y++ {
111				for x := range frame.Width {
112					frame.Set(x, y, in.frame.Cell(x, y))
113				}
114			}
115		}
116	}
117	styles := state.styles
118	if styles == (themeui.Styles{}) {
119		// Direct/test-only composition has no applied snapshot; production
120		// capture always supplies immutable styles.
121		styles = fallbackChromeStyles
122	}
123	state.styles = styles
124	defaultDimmer := themeui.NewDimmer(state.theme)
125	neutralBorder := styles.NeutralBorder
126	inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
127	// The alternate cache owns these rows; the committed cache remains intact
128	// until prepare/send succeeds. Row extraction would allocate on each paint.
129	topBar := slices.Grow(scratch.bars.top[:0], width)[:width]
130	drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
131	frame.WriteRow(0, 0, topBar)
132	bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width]
133	drawStatusBarState(bottomBar, state.bars, styles)
134	frame.WriteRow(rows+1, 0, bottomBar)
135	content := domain.Rect{Y: contentY, Width: width, Height: rows}
136	if state.layout.valid {
137		drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
138	}
139
140	full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
141	if full {
142		clearOutsideLayout(frame, content, state.layout.area)
143	}
144	titles := scratch.titleGenerations
145	if titles == nil {
146		titles = make(map[layout.PaneID]uint64, len(state.panes))
147	}
148	clear(titles)
149	damage := scratch.damage[:0]
150	for _, pane := range state.panes {
151		pl := offsetPlacement(pane.placement, 0, contentY)
152		if pl.TitleBar.Height > 0 {
153			drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
154			if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
155				damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
156			}
157			titles[pane.id] = pane.titleGeneration
158		}
159		if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160			// Reduced-color chrome never recolors or fades pane content; focus is
161			// shown by the title bar and dividers.
162			blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
163		}
164		for _, d := range pane.damage {
165			if d.Kind != renderer.DamageFullRedraw {
166				d.Y += contentY
167			}
168			damage = append(damage, d)
169		}
170	}
171	// Keep the committed cache unadorned. Toasts are composed first so floating
172	// terminals and scoped modals dim them with the rest of the complete frame.
173	baseFrame := frame
174	overlaysActive := state.overlays.active()
175	toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
176	var toastFootprints []domain.Rect
177	if toastsVisible {
178		frame = baseFrame.Clone()
179		toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
180	}
181	if state.floating.visible {
182		var floatingDamage []renderer.Damage
183		frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
184			baseFrame:    frame,
185			baseDamage:   damage,
186			floating:     state.floating,
187			content:      content,
188			theme:        state.theme,
189			borderMuted:  styles.BorderMuted,
190			borderActive: styles.BorderActive,
191			cache:        in,
192			full:         full || overlaysActive,
193		})
194		damage = floatingDamage
195	}
196	copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
197		!state.overlays.copySearchActive &&
198		!state.overlays.paletteActive && !state.overlays.promptActive &&
199		!state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
200	var copyViewport copyViewportState
201	var scrollDamage []renderer.Damage
202	if copyOnly && !toastsVisible {
203		copyViewport = captureCopyViewport(state, content)
204		if !full {
205			scrollDamage = copyViewportDamage(in.copyViewport, copyViewport, frame)
206		}
207	}
208	if overlaysActive {
209		if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight {
210			frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage)
211		} else {
212			switch {
213			case copyOnly && !toastsVisible:
214				// A plain copy viewport becomes the next viewport cache, so build
215				// it in the spare viewport page, which never shares baseFrame's.
216				frame = scratch.copyViewport.frame
217				frame.CopyFrom(baseFrame)
218			case !toastsVisible && !state.floating.visible:
219				frame = baseFrame.Clone()
220			}
221			frame, damage = composeCapturedCopyMode(state, frame, damage, content)
222		}
223		frame, damage = composeCapturedOverlays(state, frame, damage)
224	}
225	for x := range width {
226		topBar[x] = baseFrame.Cell(x, 0)
227		bottomBar[x] = baseFrame.Cell(x, rows+1)
228	}
229	if !full {
230		if !sameCells(in.bars.top, topBar) {
231			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
232		}
233		if !sameCells(in.bars.bottom, bottomBar) {
234			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
235		}
236	}
237	// The cache stays toast-free. The terminal shadow, in contrast, includes
238	// toasts, so every render damages both the last and current toast coverage.
239	// This restores cells exposed by dismissal and redraws a stable toast over
240	// any underlying pane update without promoting either case to a full frame.
241	if full || overlaysActive {
242		damage = []renderer.Damage{renderer.FullRedraw()}
243	} else {
244		damage = appendToastDamage(damage, in.toastFootprints)
245		damage = appendToastDamage(damage, toastFootprints)
246	}
247	cursorInputs := state.cursor
248	cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249	cursor := desiredCapturedCursor(cursorInputs, contentY)
250	// A plain copy viewport is painted on a clone or the spare viewport page,
251	// never into baseFrame. Keep
252	// that unadorned base reusable between wheel events. Modal/floating paths
253	// retain their conservative invalidation, and copy exit requests a reset.
254	outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
255	outCache.bars = barCache{top: topBar, bottom: bottomBar}
256	if copyViewport.document != nil {
257		copyViewport.frame = frame
258		outCache.copyViewport = copyViewport
259	}
260	if scrollDamage != nil {
261		damage = scrollDamage
262	}
263	// A copy viewport is ordinary terminal content, not a new output epoch.
264	// Keep conservative resets for other overlays and explicit resynchronization.
265	return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || (overlaysActive && !copyOnly)}
266}
267
268func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
269	if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
270		return
271	}
272	style := styles.StatusBar
273	if !focused {
274		style = dimmer.Dim(neutralBorder)
275	}
276	for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
277		frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
278	}
279	ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
280}
281
282func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
283	if !c.renderable || c.hiddenByOverlay || !c.visible {
284		return cursorOut{hidden: true}
285	}
286	style := c.style
287	if !c.hasStyle {
288		style = 1
289	}
290	return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
291}
292
293func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
294	if state == nil || snap == nil {
295		return
296	}
297	o := &state.overlays
298	o.copyActive, o.copySearchActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.paletteActive, snap.promptActive
299	o.noticesOverlayActive = snap.noticesOverlayActive
300	o.copyMode = snap.copyMode
301	o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
302	if snap.copyPane != nil {
303		o.copyPaneID = snap.copyPane.id
304	}
305	styles := state.styles
306	if styles == (themeui.Styles{}) {
307		styles = fallbackChromeStyles
308	}
309	rows := state.layout.area.Height
310	rows += tabChromeRows
311	size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
312	if snap.copySearchModel != nil {
313		presentation := copySearchModal.Resolve(size)
314		o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
315		o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
316	}
317	if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
318		presentation := noticesModal.Resolve(size)
319		o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
320		renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
321		o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
322	}
323	if snap.paletteActive && snap.paletteModel != nil {
324		modal := paletteModalFor(paletteCfg)
325		presentation := modal.Resolve(size)
326		o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
327		guidance := ""
328		if snap.paletteHints != nil {
329			guidance = snap.paletteHints.Feedback
330		}
331		o.paletteGuidance = snap.paletteFeedback
332		o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
333	}
334	if snap.promptActive && snap.promptModel != nil {
335		modal := promptModalFor(snap.promptModel.Title())
336		presentation := modal.Resolve(size)
337		o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
338		selection := styles.Selection
339		// Reduced copy selection is plain reverse; prompts retain bold emphasis.
340		if styles.Reduced {
341			selection = styles.SurfaceActive
342		}
343		o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
344	}
345	state.cursor.hiddenByOverlay = o.active()
346}
347
348func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
349	o := state.overlays
350	if !o.copyActive {
351		return frame, damage
352	}
353	target := capturedCopyTarget(state, content)
354	return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
355}
356
357func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.Rect {
358	o := state.overlays
359	target := domain.Rect{}
360	if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
361		target = state.floating.geometry.translate(content.X, content.Y).Inner
362	}
363	if target.Width == 0 {
364		id := o.copyPaneID
365		if id == "" {
366			id = state.layout.focus
367		}
368		for _, p := range state.panes {
369			if p.id == id && !p.placement.Collapsed {
370				target = p.placement.Content
371				target.Y += content.Y
372				break
373			}
374		}
375	}
376	return target
377}
378
379func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
380	o := state.overlays
381	// Paint in reverse keyboard priority so the same layer that owns input is
382	// visually topmost: prompt > palette > notices > copy search.
383	for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.palette, o.prompt} {
384		if !modal.active {
385			continue
386		}
387		applyOverlayBackdrop(frame, state.theme)
388		border := state.styles.BorderMuted
389		if modal.focused {
390			border = state.styles.BorderActive
391		}
392		(ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
393		copyModalInner(frame, modal.presentation.Inner, modal.inner)
394		damage = []renderer.Damage{renderer.FullRedraw()}
395	}
396	return frame, damage
397}
398
399// copyModalInner copies a captured model frame into its resolved destination.
400// Both source and destination are clipped so degenerate presentations remain
401// safe immutable composition inputs.
402func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
403	left := max(target.X, 0)
404	top := max(target.Y, 0)
405	right := min(target.X+target.Width, dst.Width)
406	bottom := min(target.Y+target.Height, dst.Height)
407	if left >= right || top >= bottom {
408		return
409	}
410
411	sourceX := left - target.X
412	sourceY := top - target.Y
413	width := min(right-left, src.Width-sourceX)
414	height := min(bottom-top, src.Height-sourceY)
415	if width <= 0 || height <= 0 {
416		return
417	}
418	for y := range height {
419		for x := range width {
420			dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
421		}
422	}
423}
424
425func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
426	if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
427		return nil
428	}
429	views := make([]ui.NoticeView, len(overlays.notices))
430	for i, n := range overlays.notices {
431		views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
432	}
433	return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
434}
435
436func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
437	for _, footprint := range footprints {
438		if footprint.Width > 0 && footprint.Height > 0 {
439			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
440		}
441	}
442	return damage
443}
444
445// noticeStylesFrom picks the toast box color per severity from the theme's
446// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
447// distinct from Info instead of sharing BorderMuted with it.
448func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
449	return ui.NoticeStyles{
450		Text:     styles.PickerBase,
451		BoxError: styles.BorderActive,
452		BoxWarn:  styles.BorderWarn,
453		BoxInfo:  styles.BorderMuted,
454	}
455}
456
457// commitDamageReceipts acknowledges only snapshots that reached the client.
458// sendMu serializes attachment transactions; panes are locked individually so
459// no pane lock is held while another is acquired. A stale generation is
460// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
461// the next transaction.
462func commitDamageReceipts(receipts []damageReceipt) {
463	for _, receipt := range receipts {
464		if receipt.pane != nil {
465			receipt.pane.mu.Lock()
466			receipt.pane.screen.AcknowledgeDamage(receipt.generation)
467			receipt.pane.mu.Unlock()
468			continue
469		}
470	}
471}
472
473// frameFollowUp is the work a frame publication leaves for after the
474// attachment's sendMu is released: notices, repaints, and send-error cleanup
475// may re-enter rendering or take session locks, so they never run under it.
476type frameFollowUp struct {
477	prepareErr   error
478	sendErr      error
479	sendTr       ports.ServerConnection
480	warnGraphics bool
481}
482
483// publishFrameLocked is the sole side-effecting half of the pipeline: it
484// revalidates ownership, prepares, sends, and commits or aborts one captured
485// frame. The caller holds ac.sendMu for the complete capture/compose/publish
486// transaction; publishFrameLocked never releases it. It reports false when the
487// frame was rejected before preparation. The returned follow-up must be passed
488// to settleFrame after sendMu is released.
489func (d *Daemon) publishFrameLocked(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, marks *runtimeMarkBatch) (frameFollowUp, bool) {
490	if entry == nil || entry.core() == nil || ac == nil || state == nil {
491		return frameFollowUp{}, false
492	}
493	entry.core().mu.Lock()
494	_, owned := entry.core().attachments[ac]
495	position := protocol.RoutePosition{
496		Target:      state.route.Target,
497		ActiveTabID: state.view.tabID,
498	}
499	entry.core().mu.Unlock()
500	if marks.attachmentEffect != nil {
501		if !marks.attachmentEffect.current() || state.attachment != ac {
502			return frameFollowUp{}, false
503		}
504	} else {
505		if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
506			return frameFollowUp{}, false
507		}
508		if state.lease != nil {
509			rc := attachmentRenderCoordinator(entry)
510			if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
511				return frameFollowUp{}, false
512			}
513		}
514	}
515	endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
516	prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor)
517	var data []byte
518	if prepared != nil {
519		data = prepared.data
520	}
521	endDiff(0, err == nil)
522	if err != nil {
523		return frameFollowUp{prepareErr: err}, true
524	}
525	var sendTr ports.ServerConnection
526	var sendErr error
527	rc := attachmentRenderCoordinator(entry)
528	confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence)
529	suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
530	if len(data) > 0 || prepared.ansi.context != nil {
531		sendTransport := ac.transportSnapshot()
532		sendTr = sendTransport.transport
533		endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
534		if sendTr == nil {
535			sendErr = errors.New("client transport is nil")
536		} else {
537			send := sendTr.SendOutput
538			if sendTr.Capabilities().AsyncSend {
539				send = sendTr.SendOutputAsync
540			}
541			interruptible := false
542			if marks.attachmentEffect != nil {
543				interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
544				if !interruptible {
545					sendErr = errAttachmentTransition
546				}
547			}
548			if sendErr == nil {
549				if len(data) > 0 {
550					sendErr = prepared.send(ac.echoAck.Load(), send)
551				} else {
552					sendErr = prepared.publishNoBytes(confirmUIFence, func(update protocol.UIViewUpdate) error {
553						if sendTr.Capabilities().AsyncSend {
554							return sendTr.SendServerAsync(update)
555						}
556						return sendTr.SendServer(update)
557					})
558				}
559			}
560			if marks.attachmentEffect != nil && interruptible {
561				if sendErr != nil {
562					marks.attachmentEffect.reportTransportFailure(sendTransport)
563				}
564				marks.attachmentEffect.endTransportSend()
565			}
566		}
567		emitted := sendErr == nil && prepared.sent()
568		endEmit(uint64(len(data)), emitted)
569		if sendErr == nil && !emitted {
570			return frameFollowUp{}, true
571		}
572		if sendErr != nil {
573			prepared.abort()
574		}
575	}
576	if sendErr == nil {
577		if len(data) == 0 {
578			prepared.commitNoSend()
579			if !prepared.sent() {
580				return frameFollowUp{}, true
581			}
582		}
583		ac.afterFrame.emitted(state.frameCapture)
584		// Publish only after output preparation and transport emission both
585		// succeed. A cross-session transition may publish concurrently, but its
586		// mandatory first-paint rebase waits for sendMu and therefore follows this
587		// completed output transaction.
588		ac.render.commitComposition(composed.cache)
589
590		// A successful no-byte emission also commits: its renderer shadow still
591		// represents the captured frame. Lock panes only under sendMu and with no
592		// session guard held.
593		commitDamageReceipts(state.receipts)
594		if ac.renderStages.emit != nil {
595			ac.renderStages.emit()
596		}
597
598		if position.ActiveTabID != "" && position != ac.output.lastRoutePosition {
599			if positionValidateErr := position.Validate(); positionValidateErr != nil {
600				d.log.Error("validate route position", "err", positionValidateErr)
601			} else {
602				sendTransport := ac.transportSnapshot()
603				sendTr = sendTransport.transport
604				interruptible := false
605				if marks.attachmentEffect != nil {
606					interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
607					if !interruptible {
608						sendErr = errAttachmentTransition
609					}
610				}
611				if sendErr == nil {
612					if sendTr == nil {
613						sendErr = errors.New("client transport is nil")
614					} else if sendTr.Capabilities().AsyncSend {
615						sendErr = sendTr.SendServerAsync(position)
616					} else {
617						sendErr = sendTr.SendServer(position)
618					}
619				}
620				if marks.attachmentEffect != nil && interruptible {
621					if sendErr != nil {
622						marks.attachmentEffect.reportTransportFailure(sendTransport)
623					}
624					marks.attachmentEffect.endTransportSend()
625				}
626				if sendErr == nil {
627					ac.output.lastRoutePosition = position
628				}
629			}
630		}
631		if sendErr == nil && rc != nil && prepared.ansi.boundary.ViewPublication != 0 &&
632			(marks.attachmentEffect == nil || marks.attachmentEffect.current()) {
633			if pending := rc.retireUIFence(state.lease, state.uiFence); pending != nil {
634				receipt := prepared.ansi.boundary
635				receipt.ActionID = pending.actionID
636				sendTransport := ac.transportSnapshot()
637				sendTr = sendTransport.transport
638				sendErr = sendUIReceiptLocked(marks.attachmentEffect, sendTransport, receipt)
639				if sendErr == nil {
640					rc.finishUIFence(state.lease, pending)
641				} else {
642					rc.failUIFence(state.lease, pending)
643				}
644			}
645		}
646	}
647	return frameFollowUp{sendErr: sendErr, sendTr: sendTr, warnGraphics: suppressedGraphics}, true
648}
649
650// settleFrame runs a publication's follow-up with sendMu released and no
651// attachment, session, tab, or pane lock held.
652func (d *Daemon) settleFrame(entry *session, ac *attachedClient, marks *runtimeMarkBatch, f frameFollowUp) {
653	if f.prepareErr != nil {
654		core := entry.core()
655		core.mu.Lock()
656		sessionName := core.name
657		core.mu.Unlock()
658		d.log.Error("render draw failed", "err", f.prepareErr, "session", sessionName)
659		// Without a coordinator reportError repaints synchronously. Suppress only
660		// that nested notice repaint; leave the guard before returning so a later,
661		// independent failed transaction can still notify the user.
662		if entry.renderCoordinator() == nil {
663			if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
664				return
665			}
666			d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr))
667			ac.prepareFailureFallback.Store(false)
668			return
669		}
670		d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr))
671		// Notices are session-scoped, while the attachment repaint still needs
672		// to redraw its chrome after a failed transaction.
673		d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
674		return
675	}
676	if f.warnGraphics && d.warnUnsupportedGraphics(ac) {
677		d.repaintForNotice(ac)
678	}
679	if f.sendErr != nil {
680		// A transport failure may invalidate the role gate. Release this render's
681		// admission first. Detachment freezes the gate and therefore cannot mutate
682		// ownership until any enclosing admitted operation has also ended.
683		if marks.attachmentEffect == nil {
684			d.detachOnSendError(entry, ac, f.sendTr)
685		} else {
686			// Capture the exact admitted capability, including its coordinator
687			// lease, before End permits a new attachment publication. Reserve
688			// cleanup accounting before End so terminal Wait cannot race a later Add.
689			capability := marks.attachmentEffect.capability()
690			launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, f.sendTr)
691			marks.attachmentEffect.End()
692			launchCleanup()
693		}
694	}
695}

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