Skip to content

Commit 9aee795

Browse files
author
gluck
committed
steps 2 and 3: one owner for what is open, and a bar that is a value
TWO QUESTIONS SHARED ONE ANSWER. What the keyboard does (a keyMode) and what the reader sees (a glyph and a name) were both derived, separately, from inSearch/inJump/drawer.kind -- three booleans consulted by four files, which for one frame could disagree. They are different axes and they stay two types, because help, status and queue are three things on screen while sharing modePanel. But they are not independent: drawerid.go makes the DRAWER IDENTITY the source and keyMode a function of it, so mode() is now one lookup and openDrawer() is the only thing that reads the booleans. Every drawer has a row in that table, including message and output, which have nothing to announce: an empty glyph is what "contributes no token" looks like, and a drawer with no row still gets modePanel rather than a panic -- a new drawer should behave as the pager did before it was described. THE BAR IS NOW A VALUE AND A PURE FUNCTION OVER IT (statusview.go). statusLine took a lock, formatted, shed by rank and returned a string against the one session a process has; multiplexing needs one per pane, renderable without owning the session behind it. viewOf() takes the lock and reads the clock; render() does arithmetic on strings and nothing else -- which is why the bar is finally golden-tested at seven widths instead of being a thing only a pty could answer questions about. What the row says changed, per the requirement's worked examples: ✓ · 123abc · test 9.8k/1.0m (1.0%) The aria id moved onto it, cost and the clock moved off it (to the ! panel, which keeps the cache buckets that make cost mean anything), and the key hints became verbose-only -- the box-specific hints are untouched, they live on the drawer's closing rule. Narrow is THREE rows (left, blank, right) and the blank is in the requirement; layout() reserves barRows() instead of assuming one, or the conversation loses a line to a bar that grew. Shedding survives, narrowed to its one honest use: the mantra goes to keep the bar on ONE row, and once the bar has given up on one row nothing is shed at all, because there is nothing left to buy. Validated in the flake dev shell and a private-server pty. Two things the pty caught: `nix develop` builds the flake package from the GIT TREE, so untracked files are invisible to it and the shell would not even open until the new files were staged; and the three-row form does not appear at 24 columns with this fixture, because a 3-character context figure fits after the mantra sheds -- the pane has to be 16 columns before the bar genuinely grows, which is what the reservation-versus-painting assertion actually needed.
1 parent 1e2e1f9 commit 9aee795

7 files changed

Lines changed: 624 additions & 16 deletions

File tree

‎internal/cli/drawerid.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package cli
2+
3+
// WHAT IS OPEN, and the one place that answers it.
4+
//
5+
// The bottom of the pager has two questions about the same fact: what does the
6+
// KEYBOARD do (a keyMode, see keymap.go), and what does the READER see (a
7+
// glyph and a name in the status bar). Both used to be derived, separately,
8+
// from `inSearch`/`inJump`/`drawer.kind` — three booleans consulted by four
9+
// files, which for one frame could disagree.
10+
//
11+
// They are different axes and they stay two types, because `help`, `status` and
12+
// `queue` are three different things on screen while sharing modePanel. But
13+
// they are NOT independent: the drawer identity is the SOURCE and the keyMode
14+
// is DERIVED from it. That is what makes "one owner" true rather than
15+
// aspirational. See plans/status-bar-and-modes.md §2.
16+
17+
// drawerID names a drawer. It is the string the drawer already carried —
18+
// `showList("queue", …)` — promoted to a type so the table below can be
19+
// exhaustive and a typo is a compile error rather than a silently nameless
20+
// drawer.
21+
type drawerID string
22+
23+
const (
24+
drawerNothing drawerID = ""
25+
drawerQueue drawerID = "queue"
26+
drawerNotifications drawerID = "notifications"
27+
drawerCommand drawerID = "command"
28+
drawerSearch drawerID = "search"
29+
drawerHelp drawerID = "help"
30+
drawerStatus drawerID = "status"
31+
drawerNote drawerID = "message"
32+
drawerOutput drawerID = "output"
33+
)
34+
35+
// drawerFace is how a drawer presents itself: the glyph that leads the status
36+
// bar, the word beside it under verbose, and the glyph marking its selected
37+
// row. An empty glyph is not an oversight — see the table.
38+
type drawerFace struct {
39+
glyph string
40+
name string
41+
selection string
42+
keys keyMode
43+
}
44+
45+
// drawerFaces is THE table. Every drawer has a row, including the ones with
46+
// nothing to show: if this is the source of `keys()`, then a message and a
47+
// hosted live view are identities too. They simply contribute no token to the
48+
// bar, which is what an empty glyph means.
49+
//
50+
// The glyphs are Gluck's, and they are musical on purpose: the queue is a
51+
// staff (𝄚), notifications are a clef (𝄞), a selected row is a note (♭ / ♩).
52+
// Search is ⌕ (U+2315) and not 🔍 — the bar is width-sensitive and every other
53+
// glyph here is single-width, while the emoji magnifier is double-width and
54+
// font-dependent, which would make the bar's layout a property of the reader's
55+
// terminal rather than of the program.
56+
var drawerFaces = map[drawerID]drawerFace{
57+
drawerNothing: {"", "", "", modeTranscript},
58+
drawerQueue: {"𝄚", "queue", "♭", modePanel},
59+
drawerNotifications: {"𝄞", "notifications", "♩", modePanel},
60+
drawerCommand: {"∴", "command", "", modeJump},
61+
drawerSearch: {"⌕", "search", "", modeSearch},
62+
drawerHelp: {"?", "help", "", modePanel},
63+
drawerStatus: {"!", "status", "", modePanel},
64+
drawerNote: {"", "", "", modePanel},
65+
drawerOutput: {"", "", "♭", modePanel},
66+
}
67+
68+
// face is the drawer's presentation, or the empty face for one nobody has
69+
// named. A missing row is deliberately not a panic: an unnamed drawer draws no
70+
// token and takes modePanel, which is what the pager did before this table
71+
// existed and is a safe thing for a new drawer to do before it is described.
72+
func (d drawerID) face() drawerFace {
73+
if f, ok := drawerFaces[d]; ok {
74+
return f
75+
}
76+
return drawerFace{keys: modePanel}
77+
}
78+
79+
// keys is the KEYBOARD half, derived. Nothing else in the program may compute
80+
// a keyMode from what is on screen.
81+
func (d drawerID) keys() keyMode { return d.face().keys }
82+
83+
// token is what the drawer contributes to the left of the status bar: its
84+
// glyph, plus its name under verbose. Empty for the drawers that have nothing
85+
// to announce, so the row then begins with the state.
86+
func (d drawerID) token(verbose bool) string {
87+
f := d.face()
88+
switch {
89+
case f.glyph == "":
90+
return ""
91+
case verbose && f.name != "":
92+
return f.glyph + " " + f.name
93+
default:
94+
return f.glyph
95+
}
96+
}
97+
98+
// selectionGlyph marks the row under the cursor. It is PER DRAWER, not one
99+
// global marker: the spec gives the queue a flat and notifications a quarter
100+
// note, and they are read in different postures — a queue row is a thing you
101+
// are about to act on, a notification is a thing you are reading.
102+
func (d drawerID) selectionGlyph() string {
103+
if g := d.face().selection; g != "" {
104+
return g
105+
}
106+
return "▸"
107+
}

‎internal/cli/session_status.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ type sessionStatus struct {
106106
// verbose is the BAR's verbosity (^V), not the tool-output toggle (^O).
107107
// Seeded from config; see plans/status-bar-and-modes.md §5.
108108
verbose bool
109+
// lastAt is when this conversation last MOVED, in either direction --
110+
// not when the session started, and not when the user last typed. An
111+
// agent working alone for an hour is a conversation that is moving, and a
112+
// bar that called that stale would be wrong in the case you are checking.
113+
lastAt time.Time
114+
// model is shown under verbose only.
115+
model string
109116
// notice is trouble the user must see, an error reason, an interrupt
110117
// notice: carried IN the frame buffer instead of being written straight
111118
// to the terminal. While the pager is up there is no scrollback to write
@@ -147,6 +154,32 @@ func (s *sessionStatus) beginTurn() {
147154
}
148155
s.mu.Lock()
149156
s.turn = turnStatusThinking
157+
s.lastAt = time.Now()
158+
s.mu.Unlock()
159+
}
160+
161+
// touch records that the conversation MOVED. Called on every frame that
162+
// carries content, in either direction: what the bar answers with it is "is
163+
// this stale", and a turn that is producing output is not stale even though
164+
// nobody has typed for an hour.
165+
func (s *sessionStatus) touch(at time.Time) {
166+
if s == nil || at.IsZero() {
167+
return
168+
}
169+
s.mu.Lock()
170+
if at.After(s.lastAt) {
171+
s.lastAt = at
172+
}
173+
s.mu.Unlock()
174+
}
175+
176+
// setModel records the model for the verbose bar.
177+
func (s *sessionStatus) setModel(m string) {
178+
if s == nil {
179+
return
180+
}
181+
s.mu.Lock()
182+
s.model = m
150183
s.mu.Unlock()
151184
}
152185

@@ -159,6 +192,7 @@ func (s *sessionStatus) finishTurn(reason string) {
159192
return
160193
}
161194
s.mu.Lock()
195+
s.lastAt = time.Now()
162196
reason = strings.ToLower(reason)
163197
switch {
164198
case strings.Contains(reason, "interrupt"):
@@ -396,3 +430,15 @@ func formatCtxCell(tokens int) string {
396430
return fmt.Sprintf("%d", tokens)
397431
}
398432
}
433+
434+
// barVerbose reports the BAR's verbosity under the lock. It is a method rather
435+
// than a field read because the render path asks for it on every frame, and a
436+
// racy read of a bool is still a race.
437+
func (s *sessionStatus) barVerbose() bool {
438+
if s == nil {
439+
return false
440+
}
441+
s.mu.RLock()
442+
defer s.mu.RUnlock()
443+
return s.verbose
444+
}

‎internal/cli/statusview.go‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package cli
2+
3+
// THE STATUS BAR, as a value.
4+
//
5+
// It used to be assembled: statusLine took a lock on a *sessionStatus,
6+
// formatted, shed tokens by rank and returned a string, all in one function
7+
// against the one session a process has. Multiplexing needs N of them — one per
8+
// pane — and a pane's bar must render without owning the session behind it.
9+
//
10+
// So the bar is now a VALUE and a PURE FUNCTION over it. Everything that reads
11+
// a clock, takes a lock or asks a daemon happens where the value is built;
12+
// render() does arithmetic on strings and nothing else, which is what lets it
13+
// be golden-tested at every width. See plans/status-bar-and-modes.md §1.
14+
15+
import (
16+
"strings"
17+
"time"
18+
19+
"github.com/jack-work/figaro/internal/term"
20+
"github.com/mattn/go-runewidth"
21+
)
22+
23+
// statusView is everything the bar says.
24+
type statusView struct {
25+
Drawer drawerID // what is open (drawerid.go); drawerNothing in the plain pager
26+
State turnStatus
27+
Tick uint64 // spinner frame, for the one state that animates
28+
Aria string
29+
Mantra string
30+
Ctx string // already formatted: "9.8k/1.0m (1.0%)"
31+
// Alert is the newest notification, projected into the bar. It is
32+
// TRANSIENT and its expiry is evaluated by whoever builds this value --
33+
// never here, or the pure renderer becomes a function of the wall clock
34+
// and its goldens become flaky.
35+
Alert string
36+
// LastAt is when the conversation last moved. Verbose only, and absolute:
37+
// a relative "3m ago" would be true only at the instant it was painted.
38+
LastAt time.Time
39+
Model string // verbose only
40+
Hints string // verbose only: the keys this mode answers
41+
Verbose bool
42+
}
43+
44+
// lastAtFormat is a full datetime because the question it answers -- is this
45+
// conversation stale? -- outlives a single day, and a bare wall clock is
46+
// ambiguous on a pager left open across midnight.
47+
const lastAtFormat = "01/02/06 15:04:05"
48+
49+
// render draws the bar: ONE row when both halves fit, THREE when they do not.
50+
//
51+
// Three, not two: the narrow form is left group, blank, right group, and the
52+
// blank is in the requirement. It is also the reason this returns rows rather
53+
// than a string — the caller reserves height from the same count, and a bar
54+
// that silently became taller than its reservation would eat a line of the
55+
// conversation.
56+
func (v statusView) render(w int) []string {
57+
if w <= 0 {
58+
return nil
59+
}
60+
left, right := v.groups()
61+
l, r := joinFields(left), joinFields(right)
62+
63+
if r == "" {
64+
return []string{clipToWidthEllipsis(l, w)}
65+
}
66+
if lw, rw := displayWidth(l), displayWidth(r); lw+rw+2 <= w {
67+
gap := w - lw - rw
68+
return []string{l + strings.Repeat(" ", gap) + r}
69+
}
70+
// THE MANTRA SHEDS FIRST, and only then does the bar grow a second row.
71+
// Wrapping is cheaper than losing information, but a three-row bar on a
72+
// wide-enough pane is worse than a bar without the mantra: the mantra is
73+
// the one field whose absence costs nothing that is not recoverable by
74+
// looking at the top of the screen.
75+
if v.Mantra != "" {
76+
trimmed := v
77+
trimmed.Mantra = ""
78+
if rows := trimmed.render(w); len(rows) == 1 {
79+
return rows
80+
}
81+
}
82+
return []string{
83+
clipToWidthEllipsis(l, w),
84+
"",
85+
clipToWidthEllipsis(r, w),
86+
}
87+
}
88+
89+
// groups is the split: what is about the MODE and the conversation on the left,
90+
// what is a fact about the aria on the right.
91+
func (v statusView) groups() (left, right []string) {
92+
// The alert leads, ahead of even the mode: trouble belongs where the eye
93+
// lands first, and it is the only field that arrives without being asked
94+
// for.
95+
if v.Alert != "" {
96+
left = append(left, term.NoticeInDim(v.Alert))
97+
}
98+
if tok := v.Drawer.token(v.Verbose); tok != "" {
99+
left = append(left, tok)
100+
}
101+
if st := v.stateToken(); st != "" {
102+
left = append(left, st)
103+
}
104+
if v.Aria != "" {
105+
left = append(left, v.Aria)
106+
}
107+
if m := strings.Join(strings.Fields(v.Mantra), " "); m != "" {
108+
left = append(left, truncRunes(m, 32))
109+
}
110+
if v.Verbose && v.Model != "" {
111+
left = append(left, v.Model)
112+
}
113+
if v.Verbose && v.Hints != "" {
114+
left = append(left, v.Hints)
115+
}
116+
117+
if v.Ctx != "" {
118+
right = append(right, v.Ctx)
119+
}
120+
if v.Verbose && !v.LastAt.IsZero() {
121+
right = append(right, v.LastAt.Format(lastAtFormat))
122+
}
123+
return left, right
124+
}
125+
126+
// stateToken is the state as the bar draws it: the glyph, plus its name under
127+
// verbose. Idle contributes nothing — it is the catch-all, and a row that is
128+
// always visible has nothing to say by saying "nothing is happening".
129+
func (v statusView) stateToken() string {
130+
if v.State == turnStatusIdle {
131+
return ""
132+
}
133+
sym := v.State.symbol(v.Tick)
134+
if v.Verbose {
135+
if name := v.State.name(); name != "" {
136+
return v.State.paint(name + " " + sym)
137+
}
138+
}
139+
return v.State.paint(sym)
140+
}
141+
142+
// joinFields is the separator rule, and it is the reason wrapping is done by
143+
// GROUP rather than by column: `·` joins fields ON a row, so a group that
144+
// breaks across rows must not leave one dangling at the break.
145+
func joinFields(fields []string) string { return strings.Join(fields, " · ") }
146+
147+
// ---------------------------------------------------------------------------
148+
// Building the value.
149+
// ---------------------------------------------------------------------------
150+
151+
// viewOf snapshots a session into a statusView. This is where the lock is
152+
// taken and where the wall clock is read; render() does neither.
153+
func (s *sessionStatus) viewOf(drawer drawerID, verbose bool, now time.Time) statusView {
154+
if s == nil {
155+
return statusView{}
156+
}
157+
s.mu.RLock()
158+
defer s.mu.RUnlock()
159+
v := statusView{
160+
Drawer: drawer,
161+
State: s.turn,
162+
Tick: s.tick,
163+
Aria: s.figaroID,
164+
Mantra: s.metrics.Mantra,
165+
Alert: s.notice,
166+
LastAt: s.lastAt,
167+
Verbose: verbose,
168+
}
169+
if ctx := formatContextUsage(s.metrics.ContextTokens, s.metrics.ContextLimit, s.metrics.ContextExact); ctx != "-" {
170+
v.Ctx = ctx
171+
}
172+
if verbose {
173+
v.Model = s.model
174+
}
175+
return v
176+
}
177+
178+
// barWidthOf reports how tall a bar of this view will be, so a caller that
179+
// reserves rows and a caller that draws them cannot disagree.
180+
func (v statusView) height(w int) int { return len(v.render(w)) }
181+
182+
// truncRunes is shared with the old renderer.
183+
var _ = runewidth.StringWidth

0 commit comments

Comments
 (0)