Skip to content

Commit 027fc9c

Browse files
author
gluck
committed
cli: Enter spells a notification out, and the pit paints its level
Two polish passes on the notifications pit, both asked for after living with it (Gluck, 2026-09-30). ENTER. It used to print the full date and the source under a row, which is furniture: the row already carries the time and the aria. It now spells the notification OUT, the way the state pit's Enter opens a value (formView.Activate -> openBranch): the whole text, wrapped to the pane, every line selectable and yanking the whole, so a long single line can be read rather than clipped at the pane edge. Bounded at 200 lines, as an opened form value is at 500, with the same "y yanks all of it". THE LEVEL IS A COLOUR, not only a glyph. It cannot ride in the row's text, which goes through pitText and loses every escape, so a row carries a tone (pitRow.tone) and the picker paints it: the palette's own red for an error, its yellow for a warning, nothing for info. Over the selection wash each is the same colour lifted (term.NoticeOnWash), because autumnRed on mid-grey reads at 1.9:1. A pit that sets no tone renders byte for byte as it did before, which is asserted rather than assumed. Tests, red first: two view cases (the whole text wrapped at width 60, and no date or source anywhere in it) and one picker case (wash plus lifted red on the selected row, the palette's red on an unselected one, an untoned row unchanged byte for byte, no escape at all with colour off). scripts/notifications-pty.sh grows four checks, each of which fails on 654d999 and passes here, plus the premise they rest on: the row really cannot hold the text.
1 parent 654d999 commit 027fc9c

10 files changed

Lines changed: 324 additions & 41 deletions

File tree

‎internal/cli/keymap.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ var helpRows = []helpRow{
830830
{helpSelectExtend, "^N/^P + Shift", "travel between questions (Alt+^N/^P extends a selection)"},
831831
{helpExpand, "Enter", "open tool bodies and form deltas within the selection"},
832832
{helpToolBody, "e", "open the tool bodies within the selection"},
833-
{helpPitEnter, "(in a list) Enter", "the row's own action: open a form value, attend an aria"},
833+
{helpPitEnter, "(in a list) Enter", "the row's own action: open a form value or a notification, attend an aria"},
834834
{helpVisualSelect, "(in v) Enter", "select the node under the cursor (then e expands it)"},
835835
{helpVisualAttend, "(in v) a", "attend the highlighted id, or the word under the cursor; a path opens in your editor"},
836836
{helpEscape, "Esc", "clear selection / close panel"},

‎internal/cli/keymap_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ func TestHelpBody_MatchesTheOldHandWrittenPanel(t *testing.T) {
351351
" ^N/^P + Shift travel between questions (Alt+^N/^P extends a selection)",
352352
" Enter open tool bodies and form deltas within the selection",
353353
" e open the tool bodies within the selection",
354-
" (in a list) Enter the row's own action: open a form value, attend an aria",
354+
" (in a list) Enter the row's own action: open a form value or a notification, attend an aria",
355355
" (in v) Enter select the node under the cursor (then e expands it)",
356356
" (in v) a attend the highlighted id, or the word under the cursor; a path opens in your editor",
357357
" Esc clear selection / close panel",

‎internal/cli/notifications.go‎

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -210,26 +210,41 @@ type notificationsView struct {
210210

211211
func (v *notificationsView) Close() { v.closed = true }
212212

213-
// Items is one row per notification, newest first, with an expanded row's
214-
// detail beneath it as unselectable lines.
213+
// Items is one row per notification, newest first, with an opened row's whole
214+
// text beneath it.
215215
func (v *notificationsView) Items(width int) []pitRow {
216216
v.store.markRead()
217217
items := v.store.newestFirst()
218+
if width <= 0 {
219+
width = 80
220+
}
218221
var rows []pitRow
219222
for _, n := range items {
220223
if !v.filter.keeps(n.level) {
221224
continue
222225
}
223226
id := strconv.FormatUint(n.seq, 10)
227+
yank := n.yank()
224228
rows = append(rows, pitRow{
225229
id: id,
226-
yank: n.yank(),
230+
yank: yank,
227231
text: n.row(),
232+
tone: n.level.tone(),
228233
})
229-
if v.expanded[id] {
230-
for _, l := range n.expansion() {
231-
rows = append(rows, staticRow(" "+l))
232-
}
234+
if !v.expanded[id] {
235+
continue
236+
}
237+
// AS THE STATE PIT OPENS A VALUE: the whole thing, wrapped to the
238+
// pane, each line selectable so a reader can walk a long one, and
239+
// every one of them yanks the whole. The indent is the row's
240+
// continuation, and the 2 is the picker's own selection column.
241+
for i, l := range n.fullLines(width - len(notificationIndent) - 2) {
242+
rows = append(rows, pitRow{
243+
text: notificationIndent + l,
244+
yank: yank,
245+
id: fmt.Sprintf("%s\x00%d", id, i),
246+
tone: n.level.tone(),
247+
})
233248
}
234249
}
235250
if len(rows) == 0 {
@@ -242,11 +257,18 @@ func (v *notificationsView) Items(width int) []pitRow {
242257
return rows
243258
}
244259

245-
// Activate is Enter, the pit's own action: expand the row or fold it back.
260+
// notificationIndent is what an opened line is set in by, so it reads as the
261+
// row's continuation and not as another notification.
262+
const notificationIndent = " "
263+
264+
// Activate is Enter, the pit's own action: spell the notification out, or fold
265+
// it back. A line of an opened one addresses the notification it belongs to,
266+
// so Enter there folds it, as it does over a form value.
246267
func (v *notificationsView) Activate(id string) {
247268
if v.expanded == nil {
248269
v.expanded = map[string]bool{}
249270
}
271+
id = valuePath(id)
250272
v.expanded[id] = !v.expanded[id]
251273
}
252274

@@ -263,8 +285,9 @@ func (v *notificationsView) Key(b byte) bool {
263285
}
264286

265287
// AriaOf is what `a` attends from a row: the aria it concerns, when it
266-
// concerns one.
288+
// concerns one. A line of an opened notification answers for the whole.
267289
func (v *notificationsView) AriaOf(id string) string {
290+
id = valuePath(id)
268291
for _, n := range v.store.newestFirst() {
269292
if strconv.FormatUint(n.seq, 10) == id {
270293
// A source is "cli" or an aria id, by construction; the id check
@@ -290,6 +313,19 @@ func (l alertLevel) glyph() string {
290313
return "·"
291314
}
292315

316+
// tone is the level as a COLOUR: the pit paints a row red for trouble and
317+
// yellow for a warning, the same two the bar's unread mark wears. A glyph
318+
// alone did not find an error in a list of forty.
319+
func (l alertLevel) tone() pitTone {
320+
switch l {
321+
case alertError:
322+
return toneError
323+
case alertWarn:
324+
return toneWarn
325+
}
326+
return toneNone
327+
}
328+
293329
// row is the line the pit draws: when, how bad, about what, what, how often.
294330
func (n notification) row() string {
295331
src := n.source
@@ -306,29 +342,36 @@ func (n notification) row() string {
306342
return s
307343
}
308344

309-
// expansion is what Enter shows under a row: what the row could not hold.
310-
// The whole text when there was more than a line (a one-liner is already all
311-
// on the row, and repeating it said nothing), then the full date and where it
312-
// came from, and how often when it has repeated.
313-
func (n notification) expansion() []string {
314-
var out []string
315-
if n.detail != "" {
316-
out = append(out, strings.Split(n.detail, "\n")...)
345+
// fullLines is what Enter shows: the whole of it, wrapped to the pane, as the
346+
// state pit's Enter spells out a value. The detail when the message had more
347+
// than a line, else the row's own single line, which the pane clipped.
348+
//
349+
// It used to show the full date and the source instead, which is furniture:
350+
// the row already carries the time and the aria, and what a reader wants from
351+
// a clipped error is the rest of the error.
352+
func (n notification) fullLines(width int) []string {
353+
body := n.detail
354+
if body == "" {
355+
body = n.text
317356
}
318-
src := n.source
319-
if src == "" || src == "cli" {
320-
src = "this pager"
357+
var out []string
358+
for _, l := range strings.Split(body, "\n") {
359+
// Wrapped, never clipped, and stripped at the source as well as at the
360+
// paint: a provider's refusal can carry anything. The yank is
361+
// untouched.
362+
out = append(out, wrapPlain(pitText(strings.TrimRight(l, "\r")), max(width, 20))...)
321363
}
322-
when := n.last.Format("Mon 2006-01-02 15:04:05")
323-
switch {
324-
case n.count > 1:
325-
out = append(out, fmt.Sprintf("%s, from %s: %d times since %s", when, src, n.count, n.at.Format("15:04:05")))
326-
default:
327-
out = append(out, fmt.Sprintf("%s, from %s", when, src))
364+
if len(out) > notificationLinesMax {
365+
out = append(out[:notificationLinesMax:notificationLinesMax],
366+
AndMore(len(out)-notificationLinesMax, "lines · y yanks all of it"))
328367
}
329368
return out
330369
}
331370

371+
// notificationLinesMax bounds an opened one. A message is a line or a
372+
// paragraph; anything past this is a log, and the yank is the way to read it.
373+
const notificationLinesMax = 200
374+
332375
// yank is what y copies: the whole of it, with where it came from.
333376
func (n notification) yank() string {
334377
body := n.text

‎internal/cli/notifications_test.go‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,81 @@ func TestNotificationsView(t *testing.T) {
130130
}
131131
})
132132

133-
t.Run("Enter expands a row to its whole text and folds it back", func(t *testing.T) {
133+
t.Run("Enter spells out the whole text and folds it back", func(t *testing.T) {
134134
id := v.Items(80)[0].id
135135
v.Activate(id)
136136
rows := v.Items(80)
137-
if len(rows) < 4 || !strings.Contains(rows[1].text+rows[2].text, "provider said: overloaded") || rows[1].selectable() {
138-
t.Fatalf("the detail follows its row, unselectable: %+v", rows)
137+
if len(rows) < 4 || !strings.Contains(rows[1].text+rows[2].text, "provider said: overloaded") {
138+
t.Fatalf("the whole text follows its row: %+v", rows)
139+
}
140+
// The state pit's Enter opens a value and nothing else; this one is the
141+
// same gesture. The date and the source were what it used to show, and
142+
// the row already carries the time and the aria.
143+
for _, r := range rows[1:3] {
144+
if strings.Contains(r.text, "from this pager") || strings.Contains(r.text, "3b7aff0a,") {
145+
t.Fatalf("Enter is showing furniture instead of the text: %q", r.text)
146+
}
147+
}
148+
// A value row is selectable, as an opened value's rows are in the form
149+
// pit: a reader walks a long one, and every row yanks the whole thing.
150+
if !rows[1].selectable() || rows[1].yank != rows[0].yank {
151+
t.Fatalf("an opened line must be walkable and yank the whole: %+v", rows[1])
152+
}
153+
// Enter on one of those rows folds the notification it belongs to.
154+
v.Activate(rows[1].id)
155+
if len(v.Items(80)) != 3 {
156+
t.Fatal("Enter on an opened line did not fold it")
157+
}
158+
v.Activate(id)
159+
if len(v.Items(80)) <= 3 {
160+
t.Fatal("Enter did not open it again")
139161
}
140162
v.Activate(id)
141163
if len(v.Items(80)) != 3 {
142164
t.Fatal("a second Enter folds it")
143165
}
144166
})
145167

168+
// A LONG SINGLE LINE IS WHY THIS EXISTS: the row clips at the pane edge,
169+
// and the only way to read the rest was to yank it into a pager.
170+
t.Run("Enter wraps a long single line instead of clipping it", func(t *testing.T) {
171+
s := newNotificationStore()
172+
long := "turn failed: " + strings.Repeat("provider said no ", 20)
173+
s.post(alertError, "cli", long)
174+
v := &notificationsView{store: s}
175+
id := v.Items(60)[0].id
176+
v.Activate(id)
177+
rows := v.Items(60)
178+
if len(rows) < 5 {
179+
t.Fatalf("a 350-column line opened into %d rows at width 60: %+v", len(rows), rows)
180+
}
181+
var body string
182+
for _, r := range rows[1:] {
183+
if w := displayWidth(r.text); w > 60 {
184+
t.Fatalf("an opened row is %d columns wide: %q", w, r.text)
185+
}
186+
body += strings.TrimSpace(r.text) + " "
187+
}
188+
if want := strings.Join(strings.Fields(long), " "); !strings.Contains(strings.Join(strings.Fields(body), " "), want) {
189+
t.Fatalf("the opened rows do not hold the whole line:\n got %q\nwant %q", body, want)
190+
}
191+
})
192+
193+
// THE LEVEL IS A COLOUR, not only a glyph: the picker paints the row, so
194+
// the view says which tone it wears and smuggles no escape into the text.
195+
t.Run("errors are red and warnings yellow, info plain", func(t *testing.T) {
196+
rows := v.Items(80)
197+
want := []pitTone{toneError, toneWarn, toneNone}
198+
for i, w := range want {
199+
if rows[i].tone != w {
200+
t.Fatalf("row %d (%q) wears tone %v, want %v", i, rows[i].text, rows[i].tone, w)
201+
}
202+
if strings.ContainsRune(rows[i].text, 0x1b) {
203+
t.Fatalf("row %d smuggles an escape into its text: %q", i, rows[i].text)
204+
}
205+
}
206+
})
207+
146208
t.Run("a is attend: the row's aria, and nothing for a row that has none", func(t *testing.T) {
147209
rows := v.Items(80)
148210
if got := v.AriaOf(rows[0].id); got != "3b7aff0a" {

‎internal/cli/picker.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,10 +357,10 @@ func (p *picker) lines(id pitID, w, h int) []string {
357357
}
358358
row := clipToWidth(text, w)
359359
if i == p.cursor {
360-
out = append(out, pitSelected(row))
360+
out = append(out, pitSelected(row, p.rows[i].tone))
361361
continue
362362
}
363-
out = append(out, row)
363+
out = append(out, p.rows[i].tone.paint(row))
364364
}
365365
if markBelow {
366366
out = append(out, pitGray(clipToWidth(" "+AndMore(len(p.rows)-end, ""), w)))

‎internal/cli/pit.go‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,23 @@ type pitRow struct {
4848
// It goes through the same gate as text, and is drawn at full strength
4949
// on the selected row, where dim on a wash would not read.
5050
note string
51+
// tone is the row's level, painted by the picker. It cannot ride in the
52+
// text: every row goes through pitText, which strips escapes, and it must
53+
// not, or a form value holding an SGR would repaint the pane. The zero
54+
// value is no colour, so a pit that never heard of tones is untouched.
55+
tone pitTone
5156
}
5257

58+
// pitTone is what a row's level looks like. Three, because a message has three
59+
// (see alertLevel): trouble, a warning, and everything else.
60+
type pitTone uint8
61+
62+
const (
63+
toneNone pitTone = iota
64+
toneError
65+
toneWarn
66+
)
67+
5368
// staticRow is drawn but never selected. (Not "chromeRow": that name is taken
5469
// in transcript_mouse_test.go for a different idea.)
5570
func staticRow(text string) pitRow { return pitRow{text: text} }
@@ -253,11 +268,47 @@ func pitGray(s string) string { return term.Label(s) }
253268

254269
// pitSelected is the one row in a pit drawn at full strength: it is what every
255270
// verb acts on, so it must be read first. Dim text on a dark wash was not.
256-
func pitSelected(s string) string {
271+
// A toned row keeps its colour over the wash: the selection says where you
272+
// are standing, and the tone says what you are standing on.
273+
func pitSelected(s string, tone pitTone) string {
257274
if !term.Enabled() {
258275
return s
259276
}
260-
return "\x1b[48;5;240m\x1b[38;5;255m" + s + "\x1b[39m\x1b[49m"
277+
fg := "\x1b[38;5;255m"
278+
if body := tone.washBody(); body != "" {
279+
fg = body
280+
}
281+
return "\x1b[48;5;240m" + fg + s + "\x1b[39m\x1b[49m"
282+
}
283+
284+
// paint colours a whole row and hands the pane back as it found it. An untoned
285+
// row is returned untouched, so a pit that sets no tone renders byte for byte
286+
// as it did before there were any.
287+
func (t pitTone) paint(s string) string {
288+
body := ""
289+
switch t {
290+
case toneError:
291+
body = term.NoticeBody()
292+
case toneWarn:
293+
body = term.CautionBody()
294+
}
295+
if body == "" {
296+
return s
297+
}
298+
return body + s + "\x1b[0m"
299+
}
300+
301+
// washBody is the tone over the selection wash: the same colour, lifted,
302+
// because the palette's own red on a mid-grey wash is 1.9:1 and reads as mud.
303+
// The wash is the caller's.
304+
func (t pitTone) washBody() string {
305+
switch t {
306+
case toneError:
307+
return term.NoticeOnWash()
308+
case toneWarn:
309+
return term.CautionOnWash()
310+
}
311+
return ""
261312
}
262313

263314
// pitText is the gate every pit row passes through: THE PIT PAINTS TEXT, NEVER

0 commit comments

Comments
 (0)