okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petmake schedule visible7f83187

main
7.6 KiB241 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"albedo/cli/internal/daemon/protocol"
6	"fmt"
7	"math"
8	"strconv"
9	"strings"
10	"time"
11
12	"charm.land/lipgloss/v2"
13	"github.com/charmbracelet/x/ansi"
14)
15
16// jobGrace is how long a job runs before the interface calls it background
17// work. Most model jobs finish inside it, so a job a cell starts and awaits
18// never lights the background-job chrome at all; one that survives is real
19// work and shows up as such.
20const jobGrace = 3 * time.Second
21
22// agedJobs are the live jobs past their grace: the work the sidebar and the
23// idle status line call background. A job with no start time is settled.
24func (m ChatModel) agedJobs() []protocol.KernelJob {
25	jobs := make([]protocol.KernelJob, 0, len(m.Status.RunningJobs))
26	for _, job := range m.Status.RunningJobs {
27		if job.StartedAt <= 0 || time.Since(time.UnixMilli(job.StartedAt)) >= jobGrace {
28			jobs = append(jobs, job)
29		}
30	}
31	return jobs
32}
33
34// backgroundJobCount is the background work the interface names: aged jobs,
35// plus live remote jobs the observation could only count.
36func (m ChatModel) backgroundJobCount() int64 {
37	count := int64(len(m.agedJobs()))
38	if m.Status.KernelJobs == nil {
39		return count
40	}
41	if extra := *m.Status.KernelJobs - int64(len(m.Status.RunningJobs)); extra > 0 {
42		count += extra
43	}
44	return count
45}
46
47// activeGlances lists the sidebar's sections in order, leaving out empty ones:
48// the extensions' glances (active work), then background jobs past their grace.
49func (m ChatModel) activeGlances() []PageGlance {
50	var glances []PageGlance
51	for _, g := range m.Glances {
52		if len(g.Rows) > 0 {
53			glances = append(glances, g)
54		}
55	}
56	jobs := m.agedJobs()
57	if len(jobs) == 0 {
58		return glances
59	}
60	rows := make([]PageRow, 0, len(jobs))
61	for _, job := range jobs {
62		command := oneLine(job.Command)
63		if command == "" {
64			command = "job " + job.ID
65		}
66		rows = append(rows, PageRow{Text: command, Tone: ToneActive})
67	}
68	return append(glances, PageGlance{Title: "background jobs", Rows: rows})
69}
70
71// glanceCounts counts each glance the sidebar leaves out: all of them without
72// a sidebar, the ones that did not fit its height with one.
73func (m ChatModel) glanceCounts() []string {
74	omitted := m.activeGlances()
75	if m.sidebarWidth() > 0 {
76		_, omitted = m.sidebarRows()
77	}
78	var counts []string
79	for _, g := range omitted {
80		counts = append(counts, fmt.Sprintf("%s %d", g.Title, len(g.Rows)))
81	}
82	// a stale kernel is swapped before the next turn unless a job keeps it
83	if m.Status.KernelStale && m.backgroundJobCount() > 0 {
84		counts = append([]string{"kernel older until jobs end"}, counts...)
85	}
86	return counts
87}
88
89func (m ChatModel) renderGlances() string {
90	rows, _ := m.sidebarRows()
91	return lipgloss.NewStyle().Width(m.sidebarWidth()).Render(strings.Join(rows, "\n"))
92}
93
94// sidebarRows stacks the glances, a blank row apart and at most 12 rows each,
95// within the viewport's height. A glance with no room for its title and one
96// row is returned as omitted.
97func (m ChatModel) sidebarRows() ([]string, []PageGlance) {
98	var rows []string
99	var omitted []PageGlance
100	room := m.Viewport.Height()
101	for _, g := range m.activeGlances() {
102		gap := min(1, len(rows))
103		limit := min(room-gap, 12)
104		if limit < 2 {
105			omitted = append(omitted, g)
106			continue
107		}
108		section := m.glanceRows(g, limit)
109		rows = append(rows, make([]string, gap)...)
110		rows = append(rows, section...)
111		room -= gap + len(section)
112	}
113	return rows, omitted
114}
115
116// glanceRows renders a glance's title and rows in at most limit rows, one
117// line per row.
118func (m ChatModel) glanceRows(g PageGlance, limit int) []string {
119	shown := min(limit-1, len(g.Rows))
120	if len(g.Rows) > shown {
121		shown = limit - 2
122	}
123	rows := []string{DefaultStyles.Faint.Render(fmt.Sprintf("%s · %d", g.Title, len(g.Rows)))}
124	for _, item := range g.Rows[:shown] {
125		mark, style := "○", DefaultStyles.Faint
126		switch item.Tone {
127		case ToneActive:
128			mark, style = "●", DefaultStyles.Success
129		case ToneWarning:
130			mark, style = "!", DefaultStyles.Warning
131		case ToneMuted:
132			mark = "✓"
133		}
134		label := oneLine(item.Text)
135		if item.ID != "" {
136			label = "#" + item.ID + " " + label
137		}
138		rows = append(rows, style.Render(mark)+" "+ansi.Truncate(label, max(1, m.sidebarWidth()-2), "…"))
139	}
140	if shown < len(g.Rows) {
141		rows = append(rows, DefaultStyles.Faint.Render(fmt.Sprintf("+%d more", len(g.Rows)-shown)))
142	}
143	return rows
144}
145
146func (m ChatModel) renderFooter() string {
147	width := m.chatWidth()
148	right, compact := m.contextStat()
149	commands := hint{"/", "commands"}
150	sessions := hint{"←", "sessions"}
151	candidates := []struct {
152		right string
153		left  []hint
154	}{
155		{left: []hint{sessions, commands, {"shift+↑↓", "your messages"}, {"ctrl+j", "diffs"}, {"ctrl+o", "agents"}}, right: right},
156		{left: []hint{sessions, commands, {"ctrl+j", "diffs"}, {"ctrl+o", "agents"}}, right: right},
157		{left: []hint{sessions, commands, {"ctrl+o", "agents"}}, right: compact},
158		{left: []hint{sessions, commands}, right: compact},
159		{left: []hint{commands, {"ctrl+j", "diffs"}}, right: compact},
160		{left: []hint{commands}, right: compact},
161		{left: []hint{{"/", ""}}, right: compact},
162	}
163	for _, c := range candidates {
164		left := keyHints(c.left...)
165		if gap := width - lipgloss.Width(left) - lipgloss.Width(c.right); gap > 0 {
166			return left + strings.Repeat(" ", gap) + c.right
167		}
168	}
169	return ansi.Truncate(keyHints(commands), width, "…")
170}
171
172// contextStat is how much of the prompt was read from cache, fading as the
173// provider lets its cache go, and how full the context is: "392k/396k cached
174// (38%)", with a shorter form for narrow footers. The share turns yellow near
175// the window, where compaction starts.
176func (m ChatModel) contextStat() (full, short string) {
177	usage := m.Usage
178	if usage == nil || usage.Model != "" && m.Model != "" && usage.Model != m.Model {
179		return DefaultStyles.Faint.Render("—/— cached"), DefaultStyles.Faint.Render("—/—")
180	}
181	count := func(n *int) string {
182		if n == nil || *n < 0 {
183			return "—"
184		}
185		return shortCount(*n)
186	}
187	ratio := DefaultStyles.Muted.Render(count(cachedNow(usage, time.Now())) + "/" + count(usage.PromptTokens))
188	share := ""
189	if total := contextTokens(usage); total != nil && m.window != nil && m.windowModel != nil && *m.windowModel == usage.Model {
190		pct := int(math.Round(100 * float64(*total) / float64(*m.window)))
191		style := DefaultStyles.Faint
192		if pct >= 90 {
193			style = DefaultStyles.Warning
194		}
195		share = " " + style.Render(fmt.Sprintf("(%d%%)", pct))
196	}
197	return ratio + DefaultStyles.Faint.Render(" cached") + share, ratio + share
198}
199
200// cachedNow is what a request would read from cache at now: the measured
201// count until the cache starts to fade, then each step it has reached.
202func cachedNow(u *daemon.Usage, now time.Time) *int {
203	cached := u.CachedPromptTokens
204	for _, step := range u.CacheFade {
205		if step.At > now.UnixMilli() {
206			break
207		}
208		cached = step.Cached
209	}
210	return cached
211}
212
213// contextTokens is what the context holds after a reply: the prompt and the
214// completion, when the provider did not report a total.
215func contextTokens(u *daemon.Usage) *int {
216	if u.TotalTokens != nil && *u.TotalTokens >= 0 {
217		return u.TotalTokens
218	}
219	if u.PromptTokens == nil || *u.PromptTokens < 0 {
220		return nil
221	}
222	total := *u.PromptTokens
223	if u.CompletionTokens != nil && *u.CompletionTokens > 0 {
224		total += *u.CompletionTokens
225	}
226	return &total
227}
228
229// shortCount keeps token counts to about three digits: 812, 4.2k, 392k, 1.2m.
230func shortCount(n int) string {
231	switch {
232	case n < 1_000:
233		return strconv.Itoa(n)
234	case n < 10_000:
235		return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0") + "k"
236	case n < 999_500:
237		return fmt.Sprintf("%dk", int(math.Round(float64(n)/1_000)))
238	default:
239		return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "m"
240	}
241}