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