main ece7682

status.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"sort"
6	"strconv"
7	"time"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/protocol"
12	"github.com/bnema/vev/internal/usecase/command"
13	"github.com/bnema/vev/internal/usecase/palette"
14	themeui "github.com/bnema/vev/internal/usecase/theme"
15	"github.com/bnema/vev/internal/usecase/ui"
16)
17
18const (
19	// maxMRUSessions is how many history entries Ctrl+1..9 reach.
20	maxMRUSessions     = 9
21	pulseFrameCount    = 30
22	pulseFrameInterval = 120 * time.Millisecond
23)
24
25// pulseVisible reports whether the attention bell glyph is showing (as
26// opposed to its blank beat) at frame.
27func pulseVisible(frame int) bool {
28	return frame%pulseFrameCount != 0
29}
30
31// pulseStyleFor returns the style for the attention bell glyph at frame, built
32// on top of base so the bell always keeps the caller's background (the tab's
33// accent/statusBar, or a faded MRU entry's blended colors) instead of
34// punching a default-background hole in a themed bar. On the invisible beat
35// it returns (base, false) unchanged. reduced selects the attribute-only pulse
36// for chrome built by the reduced-color policy.
37func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) {
38	if !pulseVisible(frame) {
39		return base, false
40	}
41	if reduced {
42		return themeui.PulseAttribute(base), true
43	}
44	f := frame % pulseFrameCount
45	peak := pulseFrameCount / 2
46	distance := f - peak
47	if distance < 0 {
48		distance = -distance
49	}
50	intensity := 1 - float64(distance)/float64(peak)
51	return themeui.PulseColor(base, intensity), true
52}
53
54func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
55	clearStatusRow(row, styles.SurfaceBar)
56	x := 0
57	labels := fitTabLabels(status.tabs, len(row), topRight)
58	for i, w := range status.tabs {
59		baseStyle := styles.SurfaceInactive
60		nameStyle := styles.TabInactive
61		titleStyle := styles.TabInactiveTitle
62		if w.active {
63			baseStyle = styles.SurfaceActive
64			nameStyle = styles.TabActive
65			titleStyle = styles.TabActiveTitle
66		}
67		label := labels[i]
68		writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
69		if w.attention {
70			writeStatusText(row, &x, " ", baseStyle)
71			writeBell(row, &x, frame, baseStyle, styles.Reduced)
72		}
73		writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
74		writeStatusText(row, &x, " ", baseStyle)
75	}
76	drawRightPlainText(row, topRight, x, styles.SurfaceBar)
77}
78
79func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
80	clearStatusRow(row, styles.SurfaceBar)
81	x := 0
82	rightText := composeBottomRightText(state.bottomRight, state.statusFeedback)
83	writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
84	if state.rankedRecent != nil {
85		for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
86			style := styles.SurfaceInactive // contextual ranks deliberately do not fade.
87			if sess.selected {
88				style = styles.SurfaceActive
89			}
90			drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
91		}
92	} else {
93		fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
94		for i, sess := range fittedMRU {
95			style := styles.MRUStyle(i, len(fittedMRU))
96			if state.showRanks && i < maxMRUSessions {
97				drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
98				continue
99			}
100			drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
101		}
102	}
103	drawRightPlainText(row, rightText, x, styles.SurfaceBar)
104}
105
106type rankedRecent struct {
107	rank      int
108	name      string
109	kind      recentRouteKind
110	ephemeral bool
111	attention bool
112	selected  bool
113}
114
115func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) {
116	prefixStyle := style
117	prefixStyle.Bold = true
118	writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
119	writeStatusText(row, x, sess.name, style)
120	if sess.attention {
121		writeStatusText(row, x, " ", style)
122		writeBell(row, x, attentionFrame, style, reduced)
123	}
124	writeStatusText(row, x, " ", style)
125}
126
127func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) {
128	writeStatusText(row, x, " "+entry.name, style)
129	if entry.attention {
130		writeStatusText(row, x, " ", style)
131		writeBell(row, x, attentionFrame, style, reduced)
132	}
133	writeStatusText(row, x, " ", style)
134}
135
136func composeBottomRightText(scriptText, statusFeedback string) string {
137	if scriptText == "" {
138		return statusFeedback
139	}
140	if statusFeedback == "" {
141		return scriptText
142	}
143	return scriptText + " " + statusFeedback
144}
145
146func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
147	textWidth := statusTextWidth(text)
148	if text == "" || textWidth+1+reservedLeft > len(row) {
149		return
150	}
151	x := len(row) - textWidth - 1
152	writeStatusText(row, &x, " "+text, style)
153}
154
155func statusTextWidth(text string) int {
156	width := 0
157	for _, r := range text {
158		width += renderer.RuneWidth(r)
159	}
160	return width
161}
162
163func clearStatusRow(row []renderer.Cell, style renderer.Style) {
164	blank := renderer.Cell{Rune: ' ', Style: style}
165	for i := range row {
166		row[i] = blank
167	}
168}
169
170type barState struct {
171	status         statusSnapshot
172	topRight       string
173	bottomRight    string
174	statusFeedback string
175	mru            []recentRouteDisplay
176	rankedRecent   []rankedRecent
177	// showRanks prefixes each mru entry with its Ctrl+N rank while the
178	// client holds Ctrl.
179	showRanks      bool
180	attentionFrame int
181	// theme is the client's terminal theme, if reported. Its zero value
182	// (Theme{}, Known: false) is a valid "no theme" default that resolves to
183	// the pre-theme fallback styles (see themeui.NewStyles / theme.usable).
184	theme themeui.Theme
185}
186
187type statusSnapshot struct {
188	session string
189	tabs    []statusTab
190}
191
192type statusTab struct {
193	name      string // tab display name (degradation target)
194	paneTitle string // formatPaneTitle output of the focused pane
195	active    bool
196	attention bool
197}
198
199func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
200	return s.statusSegmentsFor(nil, includeTerminalTitle)
201}
202
203func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
204	var routeSnapshot protocol.RecentRouteSnapshot
205	if ac != nil {
206		routeSnapshot = ac.routeSnapshotCopy()
207	}
208
209	s.mu.Lock()
210	defer s.mu.Unlock()
211	presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral}
212	if active, ok := activeRouteEntryForLifecycle(routeSnapshot, s.incarnation); ok {
213		presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel)
214		if active.Kind == protocol.RouteKindRemote {
215			presentation.kind = recentRouteRemote
216		}
217	}
218	name := formatRecentRouteName(presentation)
219	snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
220	activeIndex := 0
221	// An unfocused client leaves the bell of its own tab pending, so it shows
222	// it like a background tab's bell.
223	seen := ac.terminalFocus().MaySee()
224	if ac != nil {
225		view := ac.viewSnapshot()
226		for i, tb := range s.tabs {
227			if domain.TabStableID(tb.stableID) == view.tabID {
228				activeIndex = i
229				break
230			}
231		}
232	}
233	for i, tb := range s.tabs {
234		name := tabDisplayName(tb, i)
235		active := i == activeIndex
236		attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint)
237		snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
238	}
239	return snap
240}
241
242func tabDisplayName(tb *tab, index int) string {
243	if tb.name != "" {
244		return tb.name
245	}
246	return strconv.Itoa(index + 1)
247}
248
249func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot protocol.RecentRouteSnapshot) []rankedRecent {
250	if hints == nil || hints.Kind != command.ContextHintRecentSessions {
251		return nil
252	}
253	var formatted []recentRouteDisplay
254	if snapshot.Generation != 0 {
255		formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot))
256	}
257	entries := make([]rankedRecent, 0, len(hints.Recent))
258	for i, hint := range hints.Recent {
259		display := recentRouteDisplay{name: hint.Name}
260		// The hint and route snapshot are immutable interaction data. The
261		// formatted snapshot is authoritative when available, keeping ranked
262		// JRS labels identical to normal MRU labels without a live lookup.
263		if i < len(formatted) {
264			display = formatted[i]
265		}
266		entries = append(entries, rankedRecent{
267			rank:      hint.Rank,
268			name:      display.name,
269			kind:      display.kind,
270			ephemeral: display.ephemeral,
271			attention: display.attention,
272			selected:  hint.Rank == hints.SelectedRank,
273		})
274	}
275	return entries
276}
277
278// barStateForPaletteHints selects snapshot-only contextual composition when a
279// palette interaction has captured recent-route hints.
280func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
281	return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
282}
283
284// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
285// attachment. Bar scripts remain local-session-only because
286// their existing execution contract depends on local tabs and PTYs.
287func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
288	return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, protocol.RecentRouteSnapshot{})
289}
290
291func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot protocol.RecentRouteSnapshot) barState {
292	routeSnapshot := capturedRouteSnapshot
293	if routeSnapshot.Generation == 0 && ac != nil {
294		routeSnapshot = ac.routeSnapshotCopy()
295	}
296	ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
297	state := barState{statusFeedback: statusFeedback}
298	if d != nil {
299		state.attentionFrame = d.attentionFrame()
300	}
301	if cur != nil {
302		includeTerminalTitle := true
303		if d != nil {
304			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
305		}
306		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
307	}
308	if cur != nil && d != nil {
309		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
310	}
311	if ranked != nil {
312		state.rankedRecent = ranked
313	} else if routeSnapshot.Generation != 0 {
314		state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
315		state.showRanks = ac != nil && ac.ctrlHeld.Load()
316	}
317	return state
318}
319
320func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
321	return d.barStateBase(cur, statusFeedback)
322}
323
324func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
325	return d.barStateBaseFor(cur, nil, statusFeedback)
326}
327
328func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
329	state := barState{statusFeedback: statusFeedback}
330	if d != nil {
331		state.attentionFrame = d.attentionFrame()
332	}
333	if cur != nil {
334		includeTerminalTitle := true
335		if d != nil {
336			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
337		}
338		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
339	}
340	if d != nil {
341		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
342	}
343	return state
344}
345
346// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
347// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
348// always a byte offset landing on a rune boundary, since it is derived from
349// the byte length of the (possibly truncated) name prefix.
350type fittedTabLabel struct {
351	text    string
352	nameLen int
353}
354
355// fitTabLabels returns, per tab, the text drawn between its surrounding
356// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
357func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
358	reserve := 1
359	if rightText != "" {
360		reserve = statusTextWidth(rightText) + 2
361	}
362	budget := rowLen - reserve
363
364	full := make([]string, len(tabs))
365	overhead := make([]int, len(tabs))
366	widths := make([]int, len(tabs))
367	total := 0
368	for i, t := range tabs {
369		full[i] = composeTabTitle(t.name, t.paneTitle)
370		o := 2
371		if t.attention {
372			o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
373		}
374		overhead[i] = o
375		widths[i] = statusTextWidth(full[i])
376		total += o + widths[i]
377	}
378	labels := make([]fittedTabLabel, len(tabs))
379	if total <= budget {
380		for i, t := range tabs {
381			labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
382		}
383		return labels
384	}
385
386	// Overflow: greedy water-filling in ascending width order (shortest tabs
387	// first) so unused budget from short tabs flows to longer ones, while
388	// output order (labels indexed by original tab index) stays stable.
389	order := make([]int, len(tabs))
390	for i := range order {
391		order[i] = i
392	}
393	sort.SliceStable(order, func(a, b int) bool {
394		return widths[order[a]] < widths[order[b]]
395	})
396
397	remaining := budget
398	remainingTabs := len(tabs)
399	for _, i := range order {
400		share := remaining / remainingTabs
401		textBudget := share - overhead[i]
402		switch {
403		case textBudget <= 0:
404			labels[i] = fittedTabLabel{}
405		case widths[i] <= textBudget:
406			labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
407		case textBudget >= statusTextWidth(tabs[i].name)+4:
408			labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
409		default:
410			text := ui.TruncateText(tabs[i].name, textBudget)
411			labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
412		}
413		consumed := overhead[i] + min(widths[i], max(textBudget, 0))
414		remaining -= consumed
415		remainingTabs--
416	}
417	return labels
418}
419
420func recentRouteDisplayWidth(entry recentRouteDisplay) int {
421	width := 2 + statusTextWidth(entry.name)
422	if entry.attention {
423		width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
424	}
425	return width
426}
427
428func fitWholeRecentEntries[T any](entries []T, budget int, cost func(int, T) int) []T {
429	if budget <= 0 || len(entries) == 0 {
430		return nil
431	}
432	used := 0
433	for i, entry := range entries {
434		used += cost(i, entry)
435		if used > budget {
436			return entries[:i]
437		}
438	}
439	return entries
440}
441
442func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
443	reserve := 1
444	if feedback != "" {
445		reserve = statusTextWidth(feedback) + 2
446	}
447	budget := rowLen - leftUsed - reserve
448	return fitWholeRecentEntries(entries, budget, func(_ int, entry rankedRecent) int {
449		return recentRouteDisplayWidth(recentRouteDisplay{
450			name:      entry.name,
451			kind:      entry.kind,
452			ephemeral: entry.ephemeral,
453			attention: entry.attention,
454		}) + rankPrefixWidth(entry.rank)
455	})
456}
457
458func rankPrefixWidth(rank int) int {
459	return statusTextWidth(strconv.Itoa(rank) + ":")
460}
461
462// fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix
463// of each entry Ctrl+1..9 reaches to its width.
464func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
465	cost := func(i int, entry recentRouteDisplay) int {
466		width := recentRouteDisplayWidth(entry)
467		if ranked && i < maxMRUSessions {
468			width += rankPrefixWidth(i + 1)
469		}
470		return width
471	}
472	// With no feedback, keep one blank trailing cell; with feedback, reserve
473	// its " text" width plus a one-cell gap so drawRightPlainText always fits.
474	copyReserve := 1
475	if feedback != "" {
476		copyReserve = statusTextWidth(feedback) + 2
477	}
478	physicalBudget := rowLen - leftUsed - copyReserve
479	if physicalBudget <= 0 || len(entries) == 0 {
480		return nil
481	}
482
483	budget := physicalBudget
484	if feedback == "" {
485		budget -= mruFutureRightReserve(rowLen)
486		// Keep at least one recent session when it physically fits; the reserved
487		// right side is only a budget preference, not a reason to hide all recents.
488		if firstCost := cost(0, entries[0]); firstCost <= physicalBudget && budget < firstCost {
489			budget = firstCost
490		}
491	}
492	return fitWholeRecentEntries(entries, budget, cost)
493}
494
495const (
496	mruReserveMinRow  = 40
497	mruReserveDivisor = 4
498	mruReserveMin     = 12
499	mruReserveMax     = 24
500)
501
502func mruFutureRightReserve(rowLen int) int {
503	if rowLen < mruReserveMinRow {
504		return 0
505	}
506	reserve := rowLen / mruReserveDivisor
507	if reserve < mruReserveMin {
508		return mruReserveMin
509	}
510	if reserve > mruReserveMax {
511		return mruReserveMax
512	}
513	return reserve
514}
515
516// writeBell draws the attention glyph on its visible beat, or a plain cell in
517// base's style on its blank beat, so the bell never leaves a bare
518// default-background hole in a themed bar.
519func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) {
520	style, visible := pulseStyleFor(frame, base, reduced)
521	if !visible {
522		writeStatusText(row, x, " ", base)
523		return
524	}
525	writeStatusText(row, x, string(ui.AttentionGlyph), style)
526}
527
528func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
529	for _, r := range text {
530		width := renderer.RuneWidth(r)
531		if width == 0 {
532			continue
533		}
534		if *x >= len(row) || *x+width > len(row) {
535			return
536		}
537		row[*x] = renderer.Cell{Rune: r, Style: style}
538		(*x)++
539		if width == 2 {
540			row[*x] = renderer.Cell{Style: style, Continuation: true}
541			(*x)++
542		}
543	}
544}

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