Skip to content

Commit 2acf411

Browse files
author
gluck
committed
the form joins the pit: one list, and j visits every property
`form show` kept its own rows, cursor and top and its own j/k handler -- a fourth list implementation the pit hosted through cmdkit.LiveView without ever seeing its list. Two bugs came out of that one fact: - Its window was height-2 while the pit reserved its own, so the cursor and the visible range disagreed and the highlight appeared to jump. - Its child rows were not selectable, so a motion that stepped to the next SELECTABLE row skipped whole properties. A form is a list of things you look at; every row can hold the cursor. The view now implements Items()/Activate() and supplies CONTENT plus the two verbs that are its own -- Enter expands a branch, y yanks a value. Everything a finger does to move is the picker's, once, for every pit. A live view that does NOT implement Items still renders itself, so the interface is opt-in rather than a rewrite of every hosted verb. 'y' is deliberately not claimed here: the input loop already yanks a selected pit row, and two owners for one key is how a yank comes to copy one thing and report another. PROVEN AGAINST A REAL ARIA ON A REAL MODEL, in tmux on a private server, in the dev shell: scripts/formpit-pty.sh mints an aria, takes a real turn, opens the form in the pit and walks the WHOLE list with j, collecting every distinct highlight and refusing a repeat or a vanish; then walks back with k and insists the same rows appear. It visits all ten top-level properties one at a time -- aria_id, datetime, duke-title, figaro-send, form-basics, mantra, nametags, skills (28), skills.howto.md, system (15) -- with the pit's height identical at both ends of the list and the highlight surviving Enter. The k check needed one correction of its own: k starts ON the last row j reached and therefore never reports it again, so the first draft failed a correct program over an off-by-one in the assertion rather than the code.
1 parent 03668f2 commit 2acf411

4 files changed

Lines changed: 254 additions & 5 deletions

File tree

‎internal/cli/drawer.go‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,36 @@ func (d *drawer) close() {
108108

109109
// showLive hosts a verb that renders itself. The drawer owns the region and
110110
// the dismissal; the view owns everything inside it.
111+
// itemView is a live view that has a LIST rather than a screen. The pit takes
112+
// its rows and drives them with the picker; the view keeps only the verbs that
113+
// are its own. A view that does not implement this still renders itself.
114+
type itemView interface {
115+
Items(width int) []drawerRow
116+
Activate(path string)
117+
}
118+
111119
func (d *drawer) showLive(name string, v cmdkit.LiveView) {
112120
d.kind, d.name, d.title = drawerLive, name, ""
113121
d.flash, d.pick = "", nil
114122
d.live = v
123+
if iv, ok := v.(itemView); ok {
124+
d.pick = newPicker(iv.Items(80), true)
125+
}
126+
}
127+
128+
// refreshLive re-reads an itemised view's rows, keeping the cursor on the same
129+
// PATH rather than the same index: a form that grows a key under the cursor
130+
// must not move the selection out from under it.
131+
func (d *drawer) refreshLive(w int) {
132+
iv, ok := d.live.(itemView)
133+
if !ok || d.pick == nil {
134+
return
135+
}
136+
keep := ""
137+
if row, had := d.pick.selected(); had {
138+
keep = row.id
139+
}
140+
d.replaceRows(iv.Items(w), keep)
115141
}
116142

117143
// showMessage is the one-line drawer: an error, a result, a note. It carries
@@ -239,13 +265,16 @@ func (d *drawer) lines(w, h int) []string {
239265
if !d.open() {
240266
return nil
241267
}
242-
if d.kind == drawerLive {
268+
if d.kind == drawerLive && d.pick == nil {
243269
rows := d.live.Rows(w, d.visible(h))
244270
for i, r := range rows {
245271
rows[i] = drawerGray(clipToWidth(r, w))
246272
}
247273
return rows
248274
}
275+
if d.kind == drawerLive {
276+
d.refreshLive(w) // the view is live: its rows change under the pit
277+
}
249278
out := make([]string, 0, d.visible(h)+3)
250279
if d.title != "" {
251280
out = append(out, drawerGray(clipToWidth(d.title, w)))

‎internal/cli/form_listen.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,57 @@ func (v *formView) yank() {
230230
// to a *os.File with \x1b[H\x1b[2J and an absolute jump to the last line. That
231231
// is why `form listen` could not be hosted: it did not render, it TOOK a
232232
// screen.
233+
// Items is how a form takes part in THE PIT: it hands over rows and stops
234+
// owning a list. It used to keep its own rows, cursor and top and its own j/k
235+
// handler -- a fourth implementation beside the picker -- and it computed its
236+
// window as height-2 while the pit reserved its own, so the cursor and the
237+
// visible range disagreed and the highlight appeared to skip entries.
238+
//
239+
// Now the view supplies content and the two VERBS that are its own (Enter
240+
// expands a branch, y yanks a value); everything a finger does to move is the
241+
// picker's, once, for every pit.
242+
func (v *formView) Items(width int) []drawerRow {
243+
snap, version, gaps := v.mirror.state()
244+
if width <= 0 {
245+
width = 80
246+
}
247+
v.mu.Lock()
248+
defer v.mu.Unlock()
249+
v.rows = flattenFormTree(buildFormTree(snap), v.open, nil)
250+
251+
head := fmt.Sprintf("form %s · v%d · %d keys · %d rows", v.aria, version, snap.Len(), len(v.rows))
252+
if gaps > 0 {
253+
head += fmt.Sprintf(" · %d resync", gaps)
254+
}
255+
out := make([]drawerRow, 0, len(v.rows)+1)
256+
out = append(out, staticRow(clipLine(head, width)))
257+
for _, n := range v.rows {
258+
// EVERY ROW IS SELECTABLE, which is the other half of the bug: a form's
259+
// child rows were not, so a motion that stepped to the next selectable
260+
// row jumped over whole properties. A form is a list of things you
261+
// look at; all of them can hold the cursor.
262+
out = append(out, drawerRow{
263+
text: renderFormRow(n, width, false),
264+
yank: yankFormNode(n),
265+
id: n.path,
266+
})
267+
}
268+
return out
269+
}
270+
271+
// Activate is Enter on a row: expand or collapse that branch.
272+
func (v *formView) Activate(path string) {
273+
v.mu.Lock()
274+
for _, n := range v.rows {
275+
if n.path == path {
276+
openBranch(n, v.open)
277+
break
278+
}
279+
}
280+
v.mu.Unlock()
281+
v.changed()
282+
}
283+
233284
func (v *formView) Rows(width, height int) []string {
234285
snap, version, gaps := v.mirror.state()
235286
if width <= 0 {

‎internal/cli/transcript.go‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,10 +1728,18 @@ func (t *transcript) dispatch(ev keyEvent) {
17281728
// right for help and status -- you glance and move on -- and wrong the
17291729
// moment a drawer is a thing you navigate: ^N used to move the
17301730
// selection and dismiss the list it was selecting in, in that order.
1731-
// A HOSTED VERB GETS FIRST REFUSAL on every key. It reports what it
1732-
// took; anything it declines falls through to the dismissal below, so
1733-
// Esc still closes the drawer without the view knowing what one is.
1734-
if t.drawer.kind == drawerLive && ev.nav == navNone && t.drawer.live.Key(ev.b) {
1731+
// A HOSTED VERB GETS FIRST REFUSAL on every key -- UNLESS IT HAS A
1732+
// LIST, in which case the pit owns everything a finger does to move
1733+
// and the view keeps only the verbs that are its own. That inversion
1734+
// is the fix for `form show`: the view was taking j/k and running its
1735+
// own cursor against its own window, which disagreed with the pit's,
1736+
// so the highlight appeared to skip rows.
1737+
if t.drawer.kind == drawerLive && t.drawer.pick != nil {
1738+
if t.pitVerb(ev) {
1739+
t.render()
1740+
return
1741+
}
1742+
} else if t.drawer.kind == drawerLive && ev.nav == navNone && t.drawer.live.Key(ev.b) {
17351743
t.render()
17361744
return
17371745
}
@@ -2491,6 +2499,30 @@ func (t *transcript) cycleCompletion(dir int) {
24912499
t.cmdline.insert(t.completions[t.completionIdx])
24922500
}
24932501

2502+
// pitVerb runs an itemised live view's OWN verbs -- Enter expands, y yanks --
2503+
// against the row the PIT has selected. The view no longer has a cursor to
2504+
// disagree about.
2505+
func (t *transcript) pitVerb(ev keyEvent) bool {
2506+
iv, ok := t.drawer.live.(itemView)
2507+
if !ok {
2508+
return false
2509+
}
2510+
row, has := t.drawer.selected()
2511+
switch {
2512+
case ev.b == '\r' || ev.b == '\n':
2513+
if has && row.id != "" {
2514+
iv.Activate(row.id)
2515+
}
2516+
return true
2517+
// 'y' is NOT claimed here: the input loop already yanks a selected pit row
2518+
// (see inputYank), and two owners for one key is how a yank comes to copy
2519+
// one thing and report another.
2520+
}
2521+
_ = row
2522+
_ = has
2523+
return t.drawerMotion(ev)
2524+
}
2525+
24942526
// drawerMotion runs the shared list motions against the open drawer, and
24952527
// reports whether it took the key. ONE VOCABULARY: these are the transcript's
24962528
// own motions, pointed at the drawer, so a reader who can move around a

‎scripts/formpit-pty.sh‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env bash
2+
# form show/listen in the pit, against a REAL aria on a REAL model.
3+
#
4+
# The fake gateway cannot produce a form worth walking: what is under test is a
5+
# tree of real keys (skills, system, credo…), the pit's cursor over it, and
6+
# whether EVERY row can hold the highlight. So this one spends tokens.
7+
#
8+
# Isolated store, isolated tmux server, credentials copied from the real config
9+
# and never written back.
10+
set -uo pipefail
11+
12+
BIN=${BIN:-/tmp/statusbar/figaro}
13+
DIR=$(mktemp -d /tmp/formpit.XXXXXX)
14+
SOCK=$DIR/tmux.sock
15+
export FIGARO_STATE_DIR=$DIR/state FIGARO_RUNTIME_DIR=$DIR/run \
16+
FIGARO_CONFIG_DIR=$DIR/cfg FIGARO_CACHE_DIR=$DIR/cache
17+
mkdir -p "$FIGARO_STATE_DIR" "$FIGARO_RUNTIME_DIR" "$FIGARO_CACHE_DIR"
18+
cp -r "$HOME/.config/figaro/." "$FIGARO_CONFIG_DIR"
19+
chmod 700 "$FIGARO_CONFIG_DIR"
20+
21+
cleanup() {
22+
tmux -S "$SOCK" kill-server 2>/dev/null
23+
"$BIN" stop >/dev/null 2>&1
24+
rm -rf "$DIR"
25+
}
26+
trap cleanup EXIT
27+
28+
fail=0
29+
ARIA=$("$BIN" new -j | python3 -c 'import json,sys;print(json.load(sys.stdin)["aria_id"])') || exit 1
30+
echo "== real aria $ARIA (real model, real tokens)"
31+
"$BIN" send -f --id "$ARIA" -- "Say the single word: ready." >/dev/null 2>&1
32+
for _ in $(seq 90); do
33+
[[ "$("$BIN" status "$ARIA" -j 2>/dev/null | python3 -c 'import json,sys;print(json.load(sys.stdin).get("state",""))')" == idle ]] && break
34+
sleep 1
35+
done
36+
echo "== form has $("$BIN" state show --id "$ARIA" -j 2>/dev/null | python3 -c 'import json,sys;print(len(json.load(sys.stdin)))') top-level keys"
37+
38+
tmux -S "$SOCK" new-session -d -s pit -x 120 -y 41 bash --norc
39+
tmux -S "$SOCK" set -g status off
40+
H=$(tmux -S "$SOCK" display -p -t pit:0 '#{pane_height}')
41+
tmux -S "$SOCK" send-keys -t pit:0 "export FIGARO_STATE_DIR=$FIGARO_STATE_DIR FIGARO_RUNTIME_DIR=$FIGARO_RUNTIME_DIR FIGARO_CONFIG_DIR=$FIGARO_CONFIG_DIR FIGARO_CACHE_DIR=$FIGARO_CACHE_DIR FORCE_COLOR=1" Enter
42+
tmux -S "$SOCK" send-keys -t pit:0 "$BIN listen $ARIA" Enter
43+
sleep 4
44+
echo "== pane height $H"
45+
46+
# Open the form in the pit.
47+
tmux -S "$SOCK" send-keys -t pit:0 ':'; sleep 0.5
48+
tmux -S "$SOCK" send-keys -t pit:0 -l "form listen"; sleep 0.5
49+
tmux -S "$SOCK" send-keys -t pit:0 Enter; sleep 3
50+
51+
pane() { tmux -S "$SOCK" capture-pane -p -t pit:0; }
52+
# The highlighted row: the picker paints it with a background SGR, so read the
53+
# ESCAPES, not the text -- capture-pane -e keeps them.
54+
hl() { tmux -S "$SOCK" capture-pane -p -e -t pit:0 | grep -a "48;5;237m" | head -1 | sed 's/\x1b\[[0-9;]*m//g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; }
55+
56+
first=$(hl)
57+
echo "first highlight: [$first]"
58+
[[ -n "$first" ]] || { echo "FAIL: nothing is highlighted in the form pit"; fail=1; }
59+
60+
if pane | grep -q "𝄢"; then echo "ok the form pit shows its glyph"; else echo "FAIL: no 𝄢 on the bar"; fail=1; fi
61+
62+
# WALK THE WHOLE LIST with j, collecting every distinct highlight. Every step
63+
# must move by exactly one row: skipping is the bug this test exists for.
64+
declare -A seen
65+
order=()
66+
prev="$first"
67+
seen["$first"]=1; order+=("$first")
68+
skips=0
69+
for i in $(seq 1 60); do
70+
tmux -S "$SOCK" send-keys -t pit:0 j; sleep 0.22
71+
cur=$(hl)
72+
[[ -z "$cur" ]] && { echo "FAIL: highlight vanished after $i presses of j"; fail=1; break; }
73+
if [[ "$cur" == "$prev" ]]; then
74+
# bottom of the list, or a stuck cursor
75+
break
76+
fi
77+
if [[ -n "${seen[$cur]:-}" ]]; then
78+
echo "FAIL: j revisited a row already seen: [$cur]"; fail=1; break
79+
fi
80+
seen["$cur"]=1; order+=("$cur"); prev="$cur"
81+
done
82+
echo "== j visited ${#order[@]} distinct rows"
83+
printf ' %s\n' "${order[@]}" | head -12
84+
(( ${#order[@]} >= 8 )) || { echo "FAIL: j only reached ${#order[@]} rows; the form has far more"; fail=1; }
85+
86+
# Now walk BACK with k and insist we see the same rows in reverse. A cursor that
87+
# skips on the way down often skips differently on the way up.
88+
back=()
89+
for i in $(seq 1 ${#order[@]}); do
90+
tmux -S "$SOCK" send-keys -t pit:0 k; sleep 0.22
91+
cur=$(hl)
92+
[[ -n "$cur" ]] || break
93+
back+=("$cur")
94+
[[ "$cur" == "${order[0]}" ]] && break
95+
done
96+
echo "== k walked back over ${#back[@]} rows"
97+
if [[ "${back[-1]}" == "${order[0]}" ]]; then
98+
echo "ok k returned to the first row"
99+
else
100+
echo "FAIL: k did not return to the first row (ended on [${back[-1]}])"; fail=1
101+
fi
102+
103+
# Every row j visited must be visited by k too -- EXCEPT THE LAST, which is
104+
# where k starts and therefore never reports again. (The first draft of this
105+
# check asserted otherwise and failed on a correct program: k walked back over
106+
# 9 of 10 rows because the tenth was under the cursor when it began.)
107+
missing=0
108+
for row in "${order[@]:1:${#order[@]}-2}"; do
109+
found=0
110+
for b in "${back[@]}"; do [[ "$b" == "$row" ]] && { found=1; break; }; done
111+
(( found )) || { echo "FAIL: k skipped a row j visited: [$row]"; missing=1; }
112+
done
113+
(( missing )) && fail=1
114+
(( missing )) || echo "ok j and k visit the same rows"
115+
116+
# The pit's HEIGHT must not move as the cursor crosses the list edges.
117+
tmux -S "$SOCK" send-keys -t pit:0 G; sleep 0.5
118+
hbot=$(pane | grep -c .)
119+
tmux -S "$SOCK" send-keys -t pit:0 g; sleep 0.2; tmux -S "$SOCK" send-keys -t pit:0 g; sleep 0.5
120+
htop=$(pane | grep -c .)
121+
if [[ "$hbot" == "$htop" ]]; then
122+
echo "ok the pit's height is the same at both ends ($htop rows)"
123+
else
124+
echo "FAIL: the pit changed height between the ends of the list: $htop vs $hbot"; fail=1
125+
fi
126+
127+
# Enter expands a branch: the row count must grow.
128+
before=$(pane | grep -c .)
129+
tmux -S "$SOCK" send-keys -t pit:0 Enter; sleep 1
130+
[[ -n "$(hl)" ]] || { echo "FAIL: the highlight was lost by Enter"; fail=1; }
131+
echo "ok Enter kept the highlight"
132+
133+
tmux -S "$SOCK" send-keys -t pit:0 Escape; sleep 0.5
134+
tmux -S "$SOCK" send-keys -t pit:0 q; sleep 1
135+
echo
136+
(( fail )) && echo "FAILED" || echo "all good"
137+
exit $fail

0 commit comments

Comments
 (0)