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/ports"
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 s.mu.Lock()
193 defer s.mu.Unlock()
194 name := s.name
195 if s.ephemeral {
196 name += "*"
197 }
198 if ac != nil && ac.remoteOrigin != "" {
199 name += "@" + ac.remoteOrigin
200 }
201 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202 activeIndex := 0
203 if ac != nil {
204 view := ac.viewSnapshot()
205 for i, tb := range s.tabs {
206 if domain.TabStableID(tb.stableID) == view.tabID {
207 activeIndex = i
208 break
209 }
210 }
211 }
212 for i, tb := range s.tabs {
213 name := tabDisplayName(tb, i)
214 active := i == activeIndex
215 attention := tb.attention && (!active || tb.attentionVisiblePaint)
216 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
217 }
218 return snap
219}
220
221func tabDisplayName(tb *tab, index int) string {
222 if tb.name != "" {
223 return tb.name
224 }
225 return strconv.Itoa(index + 1)
226}
227
228func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent {
229 if hints == nil || hints.Kind != command.ContextHintRecentSessions {
230 return nil
231 }
232 var formatted []recentRouteDisplay
233 if snapshot.Generation != 0 {
234 formatted = formatRecentRouteSnapshot(snapshot)
235 }
236 entries := make([]rankedRecent, 0, len(hints.Recent))
237 for i, hint := range hints.Recent {
238 display := recentRouteDisplay{name: hint.Name}
239 // The hint and route snapshot are immutable interaction data. The
240 // formatted snapshot is authoritative when available, keeping ranked
241 // JRS labels identical to normal MRU labels without a live lookup.
242 if i < len(formatted) {
243 display = formatted[i]
244 }
245 entries = append(entries, rankedRecent{
246 rank: hint.Rank,
247 name: display.name,
248 kind: display.kind,
249 ephemeral: display.ephemeral,
250 attention: display.attention,
251 selected: hint.Rank == hints.SelectedRank,
252 })
253 }
254 return entries
255}
256
257// barStateForPaletteHints selects snapshot-only contextual composition when a
258// palette interaction has captured recent-route hints.
259func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
260 return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
261}
262
263// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
264// attachment. Bar scripts remain local-session-only because
265// their existing execution contract depends on local tabs and PTYs.
266func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
267 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{})
268}
269
270func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState {
271 routeSnapshot := capturedRouteSnapshot
272 if routeSnapshot.Generation == 0 && ac != nil {
273 routeSnapshot = ac.routeSnapshotCopy()
274 }
275 ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
276 state := barState{statusFeedback: statusFeedback}
277 if d != nil {
278 state.attentionFrame = d.attentionFrame()
279 }
280 if cur != nil {
281 includeTerminalTitle := true
282 if d != nil {
283 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
284 }
285 state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
286 }
287 if cur != nil && d != nil {
288 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
289 }
290 if ranked != nil {
291 state.rankedRecent = ranked
292 } else if routeSnapshot.Generation != 0 {
293 state.mru = formatRecentRouteSnapshot(routeSnapshot)
294 }
295 return state
296}
297
298func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
299 return d.barStateBase(cur, statusFeedback)
300}
301
302func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
303 return d.barStateBaseFor(cur, nil, statusFeedback)
304}
305
306func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
307 state := barState{statusFeedback: statusFeedback}
308 if d != nil {
309 state.attentionFrame = d.attentionFrame()
310 }
311 if cur != nil {
312 includeTerminalTitle := true
313 if d != nil {
314 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
315 }
316 state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
317 }
318 if d != nil {
319 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
320 }
321 return state
322}
323
324// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
325// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
326// always a byte offset landing on a rune boundary, since it is derived from
327// the byte length of the (possibly truncated) name prefix.
328type fittedTabLabel struct {
329 text string
330 nameLen int
331}
332
333// fitTabLabels returns, per tab, the text drawn between its surrounding
334// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
335func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
336 reserve := 1
337 if rightText != "" {
338 reserve = statusTextWidth(rightText) + 2
339 }
340 budget := rowLen - reserve
341
342 full := make([]string, len(tabs))
343 overhead := make([]int, len(tabs))
344 widths := make([]int, len(tabs))
345 total := 0
346 for i, t := range tabs {
347 full[i] = composeTabTitle(t.name, t.paneTitle)
348 o := 2
349 if t.attention {
350 o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
351 }
352 overhead[i] = o
353 widths[i] = statusTextWidth(full[i])
354 total += o + widths[i]
355 }
356 labels := make([]fittedTabLabel, len(tabs))
357 if total <= budget {
358 for i, t := range tabs {
359 labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
360 }
361 return labels
362 }
363
364 // Overflow: greedy water-filling in ascending width order (shortest tabs
365 // first) so unused budget from short tabs flows to longer ones, while
366 // output order (labels indexed by original tab index) stays stable.
367 order := make([]int, len(tabs))
368 for i := range order {
369 order[i] = i
370 }
371 sort.SliceStable(order, func(a, b int) bool {
372 return widths[order[a]] < widths[order[b]]
373 })
374
375 remaining := budget
376 remainingTabs := len(tabs)
377 for _, i := range order {
378 share := remaining / remainingTabs
379 textBudget := share - overhead[i]
380 switch {
381 case textBudget <= 0:
382 labels[i] = fittedTabLabel{}
383 case widths[i] <= textBudget:
384 labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
385 case textBudget >= statusTextWidth(tabs[i].name)+4:
386 labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
387 default:
388 text := ui.TruncateText(tabs[i].name, textBudget)
389 labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
390 }
391 consumed := overhead[i] + min(widths[i], max(textBudget, 0))
392 remaining -= consumed
393 remainingTabs--
394 }
395 return labels
396}
397
398func recentRouteDisplayWidth(entry recentRouteDisplay) int {
399 width := 2 + statusTextWidth(entry.name)
400 if entry.attention {
401 width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
402 }
403 return width
404}
405
406func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
407 if budget <= 0 || len(entries) == 0 {
408 return nil
409 }
410 used := 0
411 for i, entry := range entries {
412 used += cost(entry)
413 if used > budget {
414 return entries[:i]
415 }
416 }
417 return entries
418}
419
420func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
421 reserve := 1
422 if feedback != "" {
423 reserve = statusTextWidth(feedback) + 2
424 }
425 budget := rowLen - leftUsed - reserve
426 return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
427 return recentRouteDisplayWidth(recentRouteDisplay{
428 name: entry.name,
429 kind: entry.kind,
430 ephemeral: entry.ephemeral,
431 attention: entry.attention,
432 }) + statusTextWidth(strconv.Itoa(entry.rank)+":")
433 })
434}
435
436func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
437 // With no feedback, keep one blank trailing cell; with feedback, reserve
438 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
439 copyReserve := 1
440 if feedback != "" {
441 copyReserve = statusTextWidth(feedback) + 2
442 }
443 physicalBudget := rowLen - leftUsed - copyReserve
444 if physicalBudget <= 0 || len(entries) == 0 {
445 return nil
446 }
447
448 budget := physicalBudget
449 if feedback == "" {
450 budget -= mruFutureRightReserve(rowLen)
451 // Keep at least one recent session when it physically fits; the reserved
452 // right side is only a budget preference, not a reason to hide all recents.
453 if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
454 budget = firstCost
455 }
456 }
457 return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
458}
459
460const (
461 mruReserveMinRow = 40
462 mruReserveDivisor = 4
463 mruReserveMin = 12
464 mruReserveMax = 24
465)
466
467func mruFutureRightReserve(rowLen int) int {
468 if rowLen < mruReserveMinRow {
469 return 0
470 }
471 reserve := rowLen / mruReserveDivisor
472 if reserve < mruReserveMin {
473 return mruReserveMin
474 }
475 if reserve > mruReserveMax {
476 return mruReserveMax
477 }
478 return reserve
479}
480
481// writeBell draws the attention glyph on its visible beat, or a plain cell in
482// base's style on its blank beat, so the bell never leaves a bare
483// default-background hole in a themed bar.
484func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
485 style, visible := pulseStyle(frame, base)
486 if !visible {
487 writeStatusText(row, x, " ", base)
488 return
489 }
490 writeStatusText(row, x, string(ui.AttentionGlyph), style)
491}
492
493func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
494 for _, r := range text {
495 width := renderer.RuneWidth(r)
496 if width == 0 {
497 continue
498 }
499 if *x >= len(row) || *x+width > len(row) {
500 return
501 }
502 row[*x] = renderer.Cell{Rune: r, Style: style}
503 (*x)++
504 if width == 2 {
505 row[*x] = renderer.Cell{Style: style, Continuation: true}
506 (*x)++
507 }
508 }
509}