-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender.go
More file actions
244 lines (225 loc) · 7.21 KB
/
Copy pathrender.go
File metadata and controls
244 lines (225 loc) · 7.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
package gatekit
import (
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
)
// FormatDecision renders a Decision as a plain-text message, shared by every
// adapter so the actual wording only has to be right once.
//
// Overdue items are listed in full, one table per source. In full, because
// gatekit no longer rations what blocks: an item is due or it is not, and
// every due item blocks at once, so a message that showed only some of them
// would be describing a gate that does not exist. Per source, because the
// columns that matter are only comparable within one module -- an age
// against an email and an age against a group chat are not the same
// measurement, and interleaving them buries that.
//
// Held and Pending are summarized to a count rather than listed. Neither
// blocks, so neither is something to act on right now, but the counts still
// have to appear: without them a snoozed or not-yet-due backlog is invisible
// from inside a blocked prompt, and clearing the visible table only to be
// blocked again tomorrow reads as the gate having ignored the work.
//
// A block also always repeats gatekit's own escape valves, since a block is
// exactly the moment someone needs reminding how to get past it, not a
// moment to assume they remember commands from an earlier one.
func FormatDecision(dec Decision) string {
var b strings.Builder
if len(dec.Overdue) > 0 {
fmt.Fprintf(&b, "%s overdue:\n", pluralItems(len(dec.Overdue)))
for _, g := range groupByModule(dec.Overdue) {
fmt.Fprintf(&b, "\n%s (%d)\n", g.module, len(g.items))
b.WriteString(renderTable(g.items))
}
}
if s := summarizeHeld(dec.Held); s != "" {
b.WriteString("\n" + s + "\n")
}
if len(dec.Pending) > 0 {
// dec.Pending is sorted by BecomesOverdueIn, so [0] is the next one
// to come due -- the only pending fact worth a line here.
next := dec.Pending[0]
fmt.Fprintf(&b, "%d pending (next due in %s: %s)\n",
len(dec.Pending), compactDuration(next.BecomesOverdueIn.D()), next.Title)
}
if len(dec.Warnings) > 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString("Warnings:\n")
for _, w := range dec.Warnings {
fmt.Fprintf(&b, " - %s\n", w)
}
}
if len(dec.Overdue) > 0 {
b.WriteString("\nEscape: gatekit bypass | gatekit snooze <dur> | gatekit off\n")
}
return b.String()
}
// moduleGroup is one source's worth of items, kept together for its own table.
type moduleGroup struct {
module string
items []RenderItem
}
// groupByModule buckets items by source, ordering the buckets by their own
// oldest item so the most overdue source leads. Items inside a bucket keep
// the oldest-first order Evaluate already put them in.
func groupByModule(items []RenderItem) []moduleGroup {
byModule := map[string][]RenderItem{}
var order []string
for _, ri := range items {
if _, seen := byModule[ri.Module]; !seen {
order = append(order, ri.Module)
}
byModule[ri.Module] = append(byModule[ri.Module], ri)
}
sort.SliceStable(order, func(a, b int) bool {
return byModule[order[a]][0].FirstSeenAt.Before(byModule[order[b]][0].FirstSeenAt)
})
out := make([]moduleGroup, 0, len(order))
for _, m := range order {
out = append(out, moduleGroup{module: m, items: byModule[m]})
}
return out
}
// Column caps for the WHO and WHY columns. Without them one long display
// name or one verbose reason sets the width for every other row, pushing
// LINK far enough right that the table stops being scannable. LINK is last
// and uncapped: a truncated URL is not a URL.
const (
maxWhoWidth = 24
maxWhyWidth = 52
)
// renderTable lays out one source's items as an aligned AGE/WHO/WHY/LINK
// table. Widths are measured from the rows actually present, so a table of
// short names stays narrow instead of padding out to a fixed template.
func renderTable(items []RenderItem) string {
type row struct{ age, who, why, link string }
rows := make([]row, 0, len(items))
for _, ri := range items {
why := ri.Reason
if ri.From != "" {
why = "from " + ri.From + "; " + why
}
rows = append(rows, row{
age: compactDuration(ri.AgeSince.D()),
who: truncate(ri.Title, maxWhoWidth),
why: truncate(why, maxWhyWidth),
link: ri.URL,
})
}
// Widths are counted in runes, not bytes. A truncated cell ends in a
// multi-byte ellipsis, so measuring with len() pads it short and every
// clipped row's last column lands two places left of the others.
ageW, whoW, whyW := width("AGE"), width("WHO"), width("WHY")
anyLink := false
for _, r := range rows {
ageW = maxInt(ageW, width(r.age))
whoW = maxInt(whoW, width(r.who))
whyW = maxInt(whyW, width(r.why))
if r.link != "" {
anyLink = true
}
}
var b strings.Builder
writeRow := func(age, who, why, link string) {
line := " " + pad(age, ageW) + " " + pad(who, whoW)
if anyLink {
line += " " + pad(why, whyW) + " " + link
} else {
line += " " + why
}
b.WriteString(strings.TrimRight(line, " ") + "\n")
}
writeRow("AGE", "WHO", "WHY", "LINK")
for _, r := range rows {
writeRow(r.age, r.who, r.why, r.link)
}
return b.String()
}
// summarizeHeld reports how many due items are not blocking and, crucially,
// why -- a held item looks identical to a handled one from outside, so the
// reason is the whole value of the line.
func summarizeHeld(held []RenderItem) string {
if len(held) == 0 {
return ""
}
var snoozed, degraded int
for _, ri := range held {
switch {
case ri.SnoozeHeld:
snoozed++
case ri.SuppressedByDegrade:
degraded++
}
}
var why []string
if snoozed > 0 {
why = append(why, fmt.Sprintf("%d snoozed", snoozed))
}
if degraded > 0 {
why = append(why, fmt.Sprintf("%d on a degraded source", degraded))
}
s := fmt.Sprintf("%d due but held", len(held))
if len(why) > 0 {
s += " (" + strings.Join(why, ", ") + ")"
}
return s
}
// compactDuration renders an age or countdown at one unit of precision:
// "57d", "9h", "22m". Anything blocking has been waiting long enough that
// the minutes stopped mattering, and a full "1370h0m0s" is unreadable in a
// table column.
func compactDuration(d time.Duration) string {
if d < 0 {
d = 0
}
switch {
case d >= 24*time.Hour:
return fmt.Sprintf("%dd", int(d.Hours())/24)
case d >= time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
case d >= time.Minute:
return fmt.Sprintf("%dm", int(d.Minutes()))
default:
return fmt.Sprintf("%ds", int(d.Seconds()))
}
}
// truncate clips s to n runes, spending the last one on an ellipsis so a
// clipped cell is visibly clipped rather than silently reworded.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}
// width is a cell's display width in columns. Every string the renderer
// pads is either ASCII or ASCII plus the ellipsis truncate appends, so a
// rune count is the right measure here; it is not a general wcwidth.
func width(s string) int { return utf8.RuneCountInString(s) }
// pad right-fills s to w columns, measured the same way width measures.
func pad(s string, w int) string {
if n := w - width(s); n > 0 {
return s + strings.Repeat(" ", n)
}
return s
}
func pluralItems(n int) string {
if n == 1 {
return "1 item"
}
return fmt.Sprintf("%d items", n)
}
func maxInt(a, b int) int {
if b > a {
return b
}
return a
}