Skip to content

Commit 1e1d429

Browse files
author
gluck
committed
merge: the notifications pit and the :models pit (#35)
Two pits, integrated on feat/pits and validated together. NOTIFICATIONS (#34, plans/notifications.md). Everything the pager said went through one slot of the status bar and was gone ten seconds later: a turn that failed while you were reading something else left one string in memory and a line in a log nobody opens mid-conversation. Now every alert is POSTED to a 500-entry history first, and the bar and the pit are two views of it: the alert as before, a "N unread" mark that outlives it while warnings or errors are unread, and `space n` / `:notifications` for the list. Repeats coalesce with digits folded, so a poller failing every tick is one row that counts. The CLI's own WARN and ERROR records tee in. Enter spells a row out the way the state pit opens a value, and the picker paints errors red and warnings yellow. :MODELS (#33). The model is a pit you choose in, and Enter sets it. Found and fixed on the way: `[cli] notice_ttl` was documented and never applied, so every alert held the bar for the built-in ten seconds. The notifications pit was exercised in a real pty (scripts/notifications-pty.sh) as well as in the suite.
2 parents 635d7ef + 027fc9c commit 1e1d429

24 files changed

Lines changed: 1987 additions & 57 deletions

‎internal/cli/command.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ func (in *interactiveInput) note(msg string) {
9494
in.mu.Unlock()
9595
}
9696

97+
// progress is note for "under way": the bar, never the notification history.
98+
func (in *interactiveInput) progress(msg string) {
99+
in.mu.Lock()
100+
in.lt.tr.setProgressNote(msg)
101+
in.mu.Unlock()
102+
}
103+
97104
// noteLocked is note for a caller that already holds the render lock.
98105
func (in *interactiveInput) noteLocked(msg string) { in.lt.tr.setCommandNote(msg) }
99106

‎internal/cli/command_router.go‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ package cli
2020

2121
import (
2222
"context"
23+
"encoding/json"
2324
"fmt"
25+
"github.com/jack-work/figaro/api/form"
2426
"github.com/jack-work/figaro/internal/cmdkit"
2527
"strings"
2628
"sync"
@@ -96,13 +98,17 @@ func tokenize(line string) []string {
9698
// Everything else -- ls, status, doctor, state, set, unset, queue,
9799
// promote, gc, models, show... -- falls through to the real router untouched.
98100
var overlayVerbs = map[string]bool{
101+
"notifications": true, "no": true, // the pager's own history (notifications.go)
99102
"listen": true,
100103
"attend": true, "at": true,
101104
"send": true, "s": true,
102105
"new": true, "replay": true, "fork": true,
103106
"kill": true, // it can kill the aria on screen, and must then move
104107
"import": true, // reads os.Stdin for `-` (portable.go)
105108
"login": true, // an interactive OAuth flow with its own prompts
109+
// models is a PIT, not a table: a list you choose the subject's model
110+
// in (models_view.go). As a routed verb it printed to os.Stdout.
111+
"models": true,
106112
}
107113

108114
// terminalSubverbs are verb+subverb pairs that take the terminal, which a bare
@@ -232,11 +238,95 @@ func (in *interactiveInput) runOverlay(verb string, args []string) {
232238
in.commandAsync(func(ctx context.Context) (string, error) {
233239
return in.commandFork(ctx, args)
234240
})
241+
case "notifications", "no":
242+
in.mu.Lock()
243+
if len(args) > 0 && args[0] == "clear" {
244+
// Vim's `:messages clear`: the pager's copy, and only it.
245+
in.lt.tr.notes.clear()
246+
in.lt.tr.setCommandNote("notifications cleared")
247+
} else {
248+
in.lt.tr.closePanels()
249+
in.lt.tr.openNotificationsPit()
250+
in.lt.tr.render()
251+
}
252+
in.mu.Unlock()
253+
case "models":
254+
in.openModels()
235255
default:
236256
in.note(fmt.Sprintf("%s: not available inside the pager (it opens a view of its own)", verb))
237257
}
238258
}
239259

260+
// openModels hosts the models pit. Asking every provider dials, so the list is
261+
// fetched off the input goroutine and installed under the render lock, as a
262+
// live form view is (openLive).
263+
func (in *interactiveInput) openModels() {
264+
in.progress("…models")
265+
go func() {
266+
models, warnings := fetchModels(in.loaded)
267+
current := in.subjectModel()
268+
view := newModelsView(models, current, in.chooseModel)
269+
in.mu.Lock()
270+
in.lt.tr.showLivePit("models", view, false)
271+
in.mu.Unlock()
272+
if len(models) == 0 && len(warnings) > 0 {
273+
in.note("models: " + strings.Join(warnings, "; "))
274+
}
275+
}()
276+
}
277+
278+
// subjectModel is the aria on screen's system.model, or "" when it cannot be
279+
// read: the ● in the list is a courtesy, not a precondition.
280+
func (in *interactiveInput) subjectModel() string {
281+
cli := in.aria()
282+
if cli == nil {
283+
return ""
284+
}
285+
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
286+
defer cancel()
287+
resp, err := cli.Form(ctx)
288+
if err != nil {
289+
return ""
290+
}
291+
raw, ok := resp.Snapshot.Get("system.model")
292+
if !ok {
293+
return ""
294+
}
295+
return valueSummary(raw)
296+
}
297+
298+
// chooseModel is Enter in the models pit: the subject's system.model becomes
299+
// the chosen id, and system.provider the provider that listed it, in ONE
300+
// patch. A model is only meaningful to its own provider; choosing copilot's
301+
// gpt-5 while the board still said anthropic would send a name Anthropic has
302+
// never heard of on the next turn. When the provider already matches, the
303+
// write lands only the model (the store drops an unchanged key), which is
304+
// exactly `figaro set system.model <id>`. It runs on its own goroutine.
305+
func (in *interactiveInput) chooseModel(c modelChoice) {
306+
cli := in.aria()
307+
id := in.currentID()
308+
if cli == nil {
309+
in.noteErr("models: no aria on screen to set")
310+
return
311+
}
312+
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
313+
defer cancel()
314+
set := map[string]json.RawMessage{"system.model": mustJSON(c.model)}
315+
if c.provider != "" {
316+
set["system.provider"] = mustJSON(c.provider)
317+
}
318+
if _, err := cli.Set(ctx, form.Build(form.Snapshot{}, set, nil), 0); err != nil {
319+
in.noteErr("models: " + wireErrorText(err))
320+
return
321+
}
322+
in.note(fmt.Sprintf("model: %s (%s) on %s", c.model, c.provider, id))
323+
}
324+
325+
func mustJSON(s string) json.RawMessage {
326+
b, _ := json.Marshal(s)
327+
return b
328+
}
329+
240330
// ---------------------------------------------------------------------------
241331
// The router, run in-process.
242332
// ---------------------------------------------------------------------------
@@ -320,7 +410,7 @@ func pagerUncapped(argv []string) []string {
320410

321411
func (in *interactiveInput) runThroughRouter(argv []string) {
322412
argv = in.withSubject(pagerUncapped(argv))
323-
in.note("…" + strings.Join(argv, " "))
413+
in.progress("…" + strings.Join(argv, " "))
324414
go func() {
325415
out, code := in.routeCaptured(argv)
326416
lines := splitOutputLines(out.text)
@@ -472,7 +562,8 @@ func (in *interactiveInput) complete(line string) []string {
472562
// Completing the VERB itself: the router's own names, plus the overlay
473563
// aliases that exist only here.
474564
verbs := append(buildRouter("figaro", in.loaded).CommandCandidates(),
475-
cmdkit.Candidate("at", "jump to a turn or node: at <turn>[.<node>]"))
565+
cmdkit.Candidate("at", "jump to a turn or node: at <turn>[.<node>]"),
566+
cmdkit.Candidate("notifications", "what figaro said, and what went wrong (space n)"))
476567
return matchPrefix(verbs, current)
477568
}
478569
req := append([]string{"__complete", argv[0], "--describe", "--current", current, "--"}, argv[1:]...)
@@ -553,7 +644,7 @@ func (in *interactiveInput) openLive(name, spec string, full bool) {
553644
}
554645
// A pit that opens fullscreen is its own announcement.
555646
if !full {
556-
in.note("…" + name + " " + spec)
647+
in.progress("…" + name + " " + spec)
557648
}
558649
go func() {
559650
view, closeView, err := openFormView(spec, in.loaded, in.renderLocked)

‎internal/cli/forkjump.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,17 @@ func pagerAttendRow(t *transcript) {
202202
}
203203
// A pit row's id is whatever that pit counts by: a queue row's is a
204204
// queue number, and attending it would be an error message where a
205-
// no-op belongs.
206-
if rpc.ValidateAriaID(row.id) != nil {
205+
// no-op belongs. A view whose rows are ABOUT arias without being them
206+
// (a notification) says which one.
207+
id := row.id
208+
if av, ok := t.pit.live.(interface{ AriaOf(string) string }); ok {
209+
id = av.AriaOf(row.id)
210+
}
211+
if rpc.ValidateAriaID(id) != nil {
207212
t.note("that row is not an aria")
208213
return
209214
}
210-
t.attendAria(row.id)
215+
t.attendAria(id)
211216
}
212217

213218
func pagerAriaBack(t *transcript) { t.hopAria(-1) }

‎internal/cli/keymap.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ var keymap = []keyBinding{
380380
{chord: byteChord('h'), modes: inTranscript, open: opensPager, help: helpHelpPanel, pager: pagerHelpPanel},
381381
{chord: byteChord('!'), modes: inTranscript, open: opensPager, help: helpStatusPanel, pager: pagerStatusPanel},
382382
{chord: byteChord('Q'), modes: inTranscript, open: opensPager, help: helpQueuedPanel, pager: pagerQueuedPanel},
383+
// SPACE IS THE LEADER: `space n` is notifications, as `<leader>n` is in
384+
// Gluck's Neovim. n alone stays search-repeat. The second key is taken in
385+
// dispatch (transcript.leaderChord), before the mode's own rows.
386+
{chord: byteChord(' '), modes: inTranscript | inPanel, open: opensPager, help: helpNotifications, pager: pagerLeader},
383387
// 'l' is the listing and 'L' is the home listing: `:ls` and `:ls -H` under
384388
// a keystroke, uncapped because the pager scrolls (pagerUncapped).
385389
{chord: byteChord('l'), modes: inTranscript | inPanel, open: opensPager, help: helpListPit, pager: pagerListPit},
@@ -753,6 +757,7 @@ const (
753757
helpDetach
754758
helpStatusPanel
755759
helpQueuedPanel
760+
helpNotifications
756761
helpHelpPanel
757762
helpListPit
758763
helpConfirm
@@ -825,7 +830,7 @@ var helpRows = []helpRow{
825830
{helpSelectExtend, "^N/^P + Shift", "travel between questions (Alt+^N/^P extends a selection)"},
826831
{helpExpand, "Enter", "open tool bodies and form deltas within the selection"},
827832
{helpToolBody, "e", "open the tool bodies within the selection"},
828-
{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"},
829834
{helpVisualSelect, "(in v) Enter", "select the node under the cursor (then e expands it)"},
830835
{helpVisualAttend, "(in v) a", "attend the highlighted id, or the word under the cursor; a path opens in your editor"},
831836
{helpEscape, "Esc", "clear selection / close panel"},
@@ -837,6 +842,7 @@ var helpRows = []helpRow{
837842
{helpListen, "^L", "open the transcript (stays open until you close it)"},
838843
{helpStatusPanel, "!", "figaro status panel"},
839844
{helpQueuedPanel, "Q", "queued prompts panel"},
845+
{helpNotifications, "space n", "notifications: what figaro said, and what went wrong"},
840846
{helpFormPit, "S", "the form (state) in the pit"},
841847
{helpFocus, "T", "read the conversation without closing the pit (again to go back)"},
842848
{helpListPit, "l / L", "list this aria's tree / the home tree, in the pit (Enter attends, x kills)"},

‎internal/cli/keymap_equiv_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ var pagerOracle = []struct {
244244
keys map[string]string
245245
}{
246246
{"help", "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none", map[string]string{
247+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
248+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
249+
// longer falls through to the any-key dismissal: the panel stays open,
250+
// which is what lets `space n` switch pits, and the offset moves only
251+
// because an open pit keeps its rows.
252+
"0x20": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=true s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
247253
"0x09": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
248254
// ^O AND TAB ARE THE JUMPLIST, 2026-09-12, and 'a' is attend. Both
249255
// report when there is no session behind them to attend WITH, which
@@ -438,6 +444,12 @@ var pagerOracle = []struct {
438444
"0x7f": "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=true jq=\"1\" vis=none",
439445
}},
440446
{"panel+sel", "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none", map[string]string{
447+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
448+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
449+
// longer falls through to the any-key dismissal: the panel stays open,
450+
// which is what lets `space n` switch pits, and the offset moves only
451+
// because an open pit keeps its rows.
452+
"0x20": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
441453
// 'a' IS ATTEND, 2026-09-12: in a pit it acts on the selected row
442454
// (the aria a `:ls` line names), so it is a pit key and does not
443455
// dismiss the pit. Nothing else here moves: the attend itself is the
@@ -495,6 +507,12 @@ var pagerOracle = []struct {
495507
"0x78": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
496508
}},
497509
{"queued", "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none", map[string]string{
510+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
511+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
512+
// longer falls through to the any-key dismissal: the panel stays open,
513+
// which is what lets `space n` switch pits, and the offset moves only
514+
// because an open pit keeps its rows.
515+
"0x20": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=true g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
498516
"0x09": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
499517
// ^O AND TAB ARE THE JUMPLIST, 2026-09-12, and 'a' is attend. Both
500518
// report when there is no session behind them to attend WITH, which
@@ -767,6 +785,12 @@ var pagerOracle = []struct {
767785
"0x7f": "off:same fol=false srch=true q=\"\" mq=\"\" h=false s=false Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
768786
}},
769787
{"status", "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none", map[string]string{
788+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
789+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
790+
// longer falls through to the any-key dismissal: the panel stays open,
791+
// which is what lets `space n` switch pits, and the offset moves only
792+
// because an open pit keeps its rows.
793+
"0x20": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
770794
"0x09": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
771795
// ^O AND TAB ARE THE JUMPLIST, 2026-09-12, and 'a' is attend. Both
772796
// report when there is no session behind them to attend WITH, which

‎internal/cli/keymap_input_equiv_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,12 @@ var inputOracle = []struct {
391391
keys map[string]string
392392
}{
393393
{"incipit", "stop=false rest=\"\" act=false off:same fol=false srch=false q=\"\" h=false s=false Q=false g=false sel=false verb=false disc=0 canc=false clip=\"\" cpfail=false cping=false jmp=false jq=\"\"", map[string]string{
394+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
395+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
396+
// longer falls through to the any-key dismissal: the panel stays open,
397+
// which is what lets `space n` switch pits, and the offset moves only
398+
// because an open pit keeps its rows.
399+
"0x20": "stop=false rest=\"\" act=true off:bottom fol=true srch=false q=\"\" h=false s=false Q=false g=false sel=false verb=false disc=0 canc=false clip=\"\" cpfail=false cping=false jmp=false jq=\"\"",
394400
// M-m IS VERBOSE TOOL OUTPUT, 2026-09-12, which ^O used to be: the
395401
// chord went to the jumplist, Ctrl+M could not take the job (that byte
396402
// is Enter), and a plain letter cannot be live in incipit. In the ':'
@@ -594,6 +600,12 @@ var inputOracle = []struct {
594600
"csiu ^p+alt": "stop=false rest=\"\" act=true off:same fol=true srch=false q=\"\" h=false s=false Q=false g=false sel=false verb=false disc=0 canc=false clip=\"\" cpfail=false cping=false jmp=true jq=\"12\"",
595601
}},
596602
{"panel", "stop=false rest=\"\" act=true off:same fol=true srch=false q=\"\" h=false s=false Q=false g=false sel=false verb=false disc=0 canc=false clip=\"\" cpfail=false cping=false jmp=false jq=\"\"", map[string]string{
603+
// SPACE IS THE LEADER, 2026-09-30: `space n` is notifications (as
604+
// `<leader>n` is in Gluck's Neovim). A leader arms and waits, so it no
605+
// longer falls through to the any-key dismissal: the panel stays open,
606+
// which is what lets `space n` switch pits, and the offset moves only
607+
// because an open pit keeps its rows.
608+
"0x20": "stop=false rest=\"\" act=true off:bottom fol=true srch=false q=\"\" h=true s=false Q=false g=false sel=false verb=false disc=0 canc=false clip=\"\" cpfail=false cping=false jmp=false jq=\"\"",
597609
// M-m IS VERBOSE TOOL OUTPUT, 2026-09-12, which ^O used to be: the
598610
// chord went to the jumplist, Ctrl+M could not take the job (that byte
599611
// is Enter), and a plain letter cannot be live in incipit. In the ':'

‎internal/cli/keymap_test.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ func TestOpensTranscript_MatchesTheHandKeptList(t *testing.T) {
241241
0x0d, 0x0a, // Enter: open tool bodies and form deltas
242242
'e', // the tool body alone; it was 't' until 2026-09-17
243243
'v', 'V', // visual selection: a gesture about rows, which only the pager has
244+
' ', // the leader, 2026-09-30: `space n` opens notifications, a pit
244245
} {
245246
old[b] = true
246247
}
@@ -350,7 +351,7 @@ func TestHelpBody_MatchesTheOldHandWrittenPanel(t *testing.T) {
350351
" ^N/^P + Shift travel between questions (Alt+^N/^P extends a selection)",
351352
" Enter open tool bodies and form deltas within the selection",
352353
" e open the tool bodies within the selection",
353-
" (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",
354355
" (in v) Enter select the node under the cursor (then e expands it)",
355356
" (in v) a attend the highlighted id, or the word under the cursor; a path opens in your editor",
356357
" Esc clear selection / close panel",
@@ -362,6 +363,7 @@ func TestHelpBody_MatchesTheOldHandWrittenPanel(t *testing.T) {
362363
" ^L open the transcript (stays open until you close it)",
363364
" ! figaro status panel",
364365
" Q queued prompts panel",
366+
" space n notifications: what figaro said, and what went wrong",
365367
" S the form (state) in the pit",
366368
" T read the conversation without closing the pit (again to go back)",
367369
" l / L list this aria's tree / the home tree, in the pit (Enter attends, x kills)",

‎internal/cli/livelog_bridge.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@ func newLivelogTurn(out io.Writer, w, h int, settings *renderSettings, figaroID
125125
t := &livelogTurn{in: in, term: term, client: aria.NewClient(), view: view, status: status}
126126
in.Queued = t.queuedRows // the queue is live chrome in the inline view too
127127
t.tr = newTranscript(audited, w, h, view, t.client, figaroID, startedAt)
128-
if status != nil {
129-
t.tr.status = status
130-
}
128+
t.tr.setStatus(status)
131129
t.wireClient()
132130
return t
133131
}
@@ -679,6 +677,9 @@ func (t *livelogTurn) reportAt(text string, level alertLevel) {
679677
return
680678
}
681679
slog.Warn("figaro session", "report", text)
680+
// The news concerns the aria on screen, which is what the pit shows as
681+
// its source and what `a` attends from it.
682+
t.tr.notes.post(level, t.status.figaroID, text)
682683
t.status.setNoticeAt(text, level)
683684
if t.tr.active {
684685
t.tr.render()

0 commit comments

Comments
 (0)