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}