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