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

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