Skip to content

Commit 6306b10

Browse files
author
gluck
committed
cli: a rule reads both sides of the cut, and a node is the boundary
Two defects in one predicate, both reported from the screen. A TOOL'S BODY IS NOT A BLOCK OF ITS OWN. The junction asked whether the hidden row beyond the rule drew the same gutter, so a tool scrolled until only its output showed got a bare rule: the row above (its `✓ $ …` header) carries no gutter, though it is the same block, and the line the reader sees plainly runs under the chrome. "Cut" now asks about the BLOCK: the hidden row draws the same gutter, OR belongs to the same block. A tool that BEGINS under the rule still joins nothing. AND THE OTHER SIDE WAS NEVER ASKED. Only the row below the rule could cut it, so a quoted question, whose own gutter runs under the header, could not. Each side is asked on its own now and all four glyphs happen: ┼ when one line passes straight through, ┴ when only the question above is cut, ┬ when only the block below is, ─ when neither. Two lines cut at different columns get a stroke each. The floor calls the same function with an empty lower side: there is nothing under it but the status bar.
1 parent 567973f commit 6306b10

4 files changed

Lines changed: 214 additions & 54 deletions

File tree

‎internal/cli/gutter_junction_test.go‎

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -144,30 +144,39 @@ func TestStickyRuleJoinsOnlyTheNextRowsGutter(t *testing.T) {
144144
}
145145

146146
func TestJoinRule(t *testing.T) {
147-
// far is the row on the other side of the rule: the one the chrome covers.
148-
// A gutter on both sides is CUT by the rule and gets a junction; a gutter
149-
// that begins or ends against it is a whole line and gets none.
147+
// A stroke says the line GOES ON past the rule on that side. The row a
148+
// reader sees carries the gutter; what the chrome hides beyond it says
149+
// whether the line is cut there or simply begins (or ends) against it.
150+
gutter := " │ x"
150151
for _, tc := range []struct {
151-
name, near, far, want string
152+
name string
153+
above, below gutterCut
154+
want string
152155
}{
153-
{"tool gutter", " │ result", " │ result", "──┬─────"},
154-
{"left spine", "│ mantra", "│ mantra", "┬───────"},
155-
{"styled gutter", "\x1b[2m │\x1b[0m result", " │ x", "──┬─────"},
156-
{"split style", " \x1b[2m │\x1b[m", " │", "──┬─────"},
157-
{"prose", " ordinary text", " │ x", "────────"},
158-
{"embedded vertical", " text │ table", " │ x", "────────"},
159-
{"ascii pipe", " | plain", " │ x", "────────"},
160-
{"corner", " ╯", " │ x", "────────"},
161-
{"blank", "\x1b[2m \x1b[m", " │ x", "────────"},
162-
{"past width", " │", " │", "────────"},
163-
{"last column", " │", " │", "───────┬"},
164-
{"block boundary", " │ first row", "∨ The Boss", "────────"},
165-
{"nothing beyond", " │ last row", "", "────────"},
166-
{"gutter moved", " │ here", " │ there", "────────"},
156+
{"nothing at all", gutterCut{}, gutterCut{}, "────────"},
157+
{"a block that ends against the rule", gutterCut{row: gutter}, gutterCut{}, "────────"},
158+
{"a block that begins under it", gutterCut{}, gutterCut{row: gutter}, "────────"},
159+
{"cut from above", gutterCut{row: gutter, hidden: gutter}, gutterCut{}, "──┴─────"},
160+
{"cut from below", gutterCut{}, gutterCut{row: gutter, hidden: gutter}, "──┬─────"},
161+
{"one line straight through", gutterCut{row: gutter, hidden: gutter},
162+
gutterCut{row: gutter, hidden: gutter}, "──┼─────"},
163+
{"two lines, two columns", gutterCut{row: gutter, hidden: gutter},
164+
gutterCut{row: " │ x", hidden: " │ y"}, "──┴┬────"},
165+
// A tool's header carries no gutter and its body is the same block, so
166+
// a rule between them cuts ONE line.
167+
{"a tool cut under its header", gutterCut{},
168+
gutterCut{row: gutter, hidden: "✓ $ ls", sameBlock: true}, "──┬─────"},
169+
{"left spine", gutterCut{}, gutterCut{row: "│ mantra", hidden: "│ mode"}, "┬───────"},
170+
{"styled gutter", gutterCut{}, gutterCut{row: "\x1b[2m │\x1b[0m x", hidden: gutter}, "──┬─────"},
171+
{"embedded vertical", gutterCut{}, gutterCut{row: " text │ table", hidden: gutter}, "────────"},
172+
{"ascii pipe", gutterCut{}, gutterCut{row: " | plain", hidden: gutter}, "────────"},
173+
{"past the rule's width", gutterCut{}, gutterCut{row: " │", hidden: " │"}, "────────"},
174+
{"last column", gutterCut{}, gutterCut{row: " │", hidden: " │"}, "───────┬"},
175+
{"columns disagree", gutterCut{}, gutterCut{row: " │ here", hidden: " │ there"}, "────────"},
167176
} {
168177
t.Run(tc.name, func(t *testing.T) {
169178
rule := "\x1b[2m────────\x1b[m"
170-
got := joinRule(rule, tc.near, tc.far, "┬")
179+
got := joinRule(rule, tc.above, tc.below)
171180
if want := "\x1b[2m" + tc.want + "\x1b[m"; got != want {
172181
t.Fatalf("got %q, want %q", got, want)
173182
}
@@ -252,3 +261,76 @@ func TestFooterRuleJoinsOnlyAGutterItCuts(t *testing.T) {
252261
t.Fatalf("the last row of a block joins nothing, got %q", rule)
253262
}
254263
}
264+
265+
// quotedPager is a turn whose question is a QUOTE (a markdown blockquote, as
266+
// the quoting gesture writes one), so the question itself has a gutter that
267+
// can meet the header's rule from above. n says how many lines of it.
268+
func quotedPager(t testing.TB, lines int) *transcript {
269+
t.Helper()
270+
quote := "> quoting aria bd0cf2bd · turn 3 · lt 63.1 · chars 3033-3067 (34 chars)"
271+
for range lines - 1 {
272+
quote += "\n> and another line of the passage, long enough to stand on its own row"
273+
}
274+
client := aria.NewClient()
275+
client.Apply(aria.Page{Parts: []aria.TurnPart{{Turn: aria.Turn{
276+
ID: 1, Sealed: true, Inquiry: quote,
277+
InquirySegments: []aria.InquirySegment{{Sender: "Gluck", Text: quote}},
278+
Nodes: []livedoc.Node{
279+
{Type: livedoc.NodeTool, Name: "bash", Status: livedoc.StatusOK,
280+
Args: map[string]any{"command": "grep -rn federate ."},
281+
Output: "(no output)\nsecond line\nthird line\nfourth line"},
282+
{Type: livedoc.NodeThinking, Markdown: "Thinking about the loop."},
283+
},
284+
}}}}, aria.Notify)
285+
view := &ariaView{settings: &renderSettings{sticky: true}}
286+
tr := newTranscript(ldrender.NewFakeTerminal(120, 12), 120, 12, view, client, "quoted01", time.Time{})
287+
tr.enter()
288+
tr.follow = false
289+
tr.buildIndex()
290+
return tr
291+
}
292+
293+
// stickyRuleOver scrolls until the row under the header's rule holds want, and
294+
// answers the rule as painted.
295+
func stickyRuleOver(t *testing.T, tr *transcript, want string) string {
296+
t.Helper()
297+
_, maxOff := tr.layoutNow()
298+
for off := 1; off <= maxOff; off++ {
299+
tr.offset = off
300+
lines := tr.stickyLines("", selectionSpan{})
301+
if len(lines) == 0 {
302+
continue
303+
}
304+
below := stripANSI(tr.lineAt(tr.offset + len(lines)))
305+
if strings.Contains(below, want) {
306+
return stripANSI(lines[len(lines)-1])
307+
}
308+
}
309+
t.Fatalf("no offset puts %q under the header's rule", want)
310+
return ""
311+
}
312+
313+
// A NODE IS THE BOUNDARY, NOT ITS GUTTER. A tool's body carries the gutter and
314+
// its header row does not, so a rule between the two cuts ONE block and says
315+
// so, where a rule above a block that starts there says nothing.
316+
func TestStickyRuleReadsBothSidesOfTheCut(t *testing.T) {
317+
// One line of question: nothing of it is left under the rule, so the only
318+
// stroke is the tool's body, cut from its header above.
319+
one := quotedPager(t, 1)
320+
if rule := stickyRuleOver(t, one, "(no output)"); !strings.HasPrefix(rule, "──┬─") {
321+
t.Errorf("a tool cut under its own header: %q", rule)
322+
}
323+
if rule := stickyRuleOver(t, one, "$ grep"); strings.ContainsAny(rule, "┬┴┼") {
324+
t.Errorf("a tool that begins under the rule joins nothing: %q", rule)
325+
}
326+
327+
// Several lines of question: its own gutter runs under the header too, so
328+
// the rule carries the stroke for it as well.
329+
many := quotedPager(t, 3)
330+
if rule := stickyRuleOver(t, many, "(no output)"); !strings.HasPrefix(rule, "──┼─") {
331+
t.Errorf("two cut lines make a crossing: %q", rule)
332+
}
333+
if rule := stickyRuleOver(t, many, "$ grep"); !strings.HasPrefix(rule, "──┴─") {
334+
t.Errorf("the question alone is cut: %q", rule)
335+
}
336+
}

‎internal/cli/transcript.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,11 +1465,12 @@ func (t *transcript) renderFrame() {
14651465
// THE RULE THAT CLOSES THE CONVERSATION CARRIES THE JUNCTION, as the
14661466
// sticky header's does and for the same reason (see joinRule). Which rule
14671467
// that is depends on what is open: the pit's own when a panel is up, the
1468-
// footer's otherwise. A clipped stanza has lost its rule along with its
1469-
// head, and a fullscreen pit has no conversation under it to join.
1470-
floor := t.lineAt(t.offset + body)
1468+
// footer's otherwise. Nothing is below it but the status bar, so the floor
1469+
// only ever wears the up stroke. A clipped stanza has lost its rule along
1470+
// with its head, and a fullscreen pit has no conversation under it.
1471+
floor := t.lineCut(t.offset+body-1, t.offset+body)
14711472
if len(foot) > 0 && !clipped && !t.fullPit() && start > 0 {
1472-
foot[0] = joinRule(foot[0], screen[start-1], floor, "┴")
1473+
foot[0] = joinRule(foot[0], floor, gutterCut{})
14731474
}
14741475
for k, l := range foot {
14751476
if r := start + k; r >= 0 && r < t.h-len(bar) {
@@ -1490,7 +1491,7 @@ func (t *transcript) renderFrame() {
14901491
// however many rows that is.
14911492
if top := t.h - 1 - len(bar); top >= 0 {
14921493
if rule != "" && len(foot) == 0 && top > 0 {
1493-
rule = joinRule(rule, screen[top-1], floor, "┴")
1494+
rule = joinRule(rule, floor, gutterCut{})
14941495
}
14951496
screen[top] = rule
14961497
for i, row := range bar {

‎internal/cli/transcript_sticky.go‎

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -174,33 +174,98 @@ func (t *transcript) stickyLines(hl string, sel selectionSpan) []string {
174174
out[0] = ldrender.OverlayRight(out[0], term.Dim(coordLabel(turn, inquiryNode, 0, t.coordFormat())+" "), t.w-ldrender.GutterCols)
175175
out[0] = ldrender.OverlayGutter(out[0], q.gutter, t.w)
176176
if above >= len(q.rows) {
177-
// The header covers rows rather than pushing them down. Join only
178-
// to the first row still visible below its rule, never a covered row.
179-
// The covered row directly above it says whether the gutter is CUT by
180-
// the rule or merely begins under it: see joinRule.
181-
rule := t.transRule()
182-
below := t.lineAt(t.offset + len(out) + 1)
183-
hidden := t.lineAt(t.offset + len(out))
184-
out = append(out, joinRule(rule, below, hidden, "┬"))
177+
// The header COVERS rows rather than pushing them down, so a gutter
178+
// may run under it on either side. The rule says which: see joinRule.
179+
seen := len(out)
180+
out = append(out, joinRule(t.transRule(),
181+
t.stickyCut(q, rows, seen),
182+
t.lineCut(t.offset+seen+1, t.offset+seen)))
185183
}
186184
return out
187185
}
188186

189-
// joinRule ties a horizontal rule to the vertical gutter that crosses it.
190-
// A JUNCTION MEANS THE LINE GOES ON PAST THE RULE, and nothing else, so it is
191-
// drawn only where the gutter stands on BOTH sides: near, the row the reader
192-
// can see, and far, the row the chrome covers. A gutter that begins or ends
193-
// against the rule is a whole line already, and a junction there branches to
194-
// a block that is not there.
195-
func joinRule(rule, near, far, glyph string) string {
196-
col, ok := gutterColumn(near)
197-
if !ok || col >= displayWidth(rule) {
198-
return rule
199-
}
200-
if other, ok := gutterColumn(far); !ok || other != col {
201-
return rule
202-
}
203-
return ldrender.OverlayColumn(rule, col, glyph)
187+
// stickyCut is the gutter meeting the header's rule FROM ABOVE: the last
188+
// pinned row carries one, and the question goes on under the rule.
189+
func (t *transcript) stickyCut(q stickyQuestion, rows []transcriptRow, seen int) gutterCut {
190+
cut := gutterCut{row: t.rowLine(rows[len(rows)-1], "", selectionSpan{})}
191+
if seen < len(q.rows) {
192+
cut.hidden = q.rows[seen].text
193+
cut.sameBlock = q.rows[seen].ref == rows[len(rows)-1].ref
194+
}
195+
return cut
196+
}
197+
198+
// lineCut is the gutter meeting a rule from the side the reader can see, where
199+
// `seen` is that row's absolute line and `hidden` the line the chrome covers
200+
// on the far side of the rule.
201+
func (t *transcript) lineCut(seen, hidden int) gutterCut {
202+
ref := t.refAtLine(seen)
203+
return gutterCut{
204+
row: t.lineAt(seen),
205+
hidden: t.lineAt(hidden),
206+
sameBlock: ref.valid() && ref == t.refAtLine(hidden),
207+
}
208+
}
209+
210+
// refAtLine is the block an absolute line belongs to, or the zero ref for
211+
// chrome, a separator or a gap.
212+
func (t *transcript) refAtLine(i int) nodeRef {
213+
k := t.index.entryAt(i)
214+
if k < 0 {
215+
return nodeRef{}
216+
}
217+
e := &t.index.entries[k]
218+
return e.refAt(i - e.start)
219+
}
220+
221+
// gutterCut is one side of a rule: the row the reader SEES there, plus what
222+
// the chrome hides immediately beyond it.
223+
type gutterCut struct {
224+
row string
225+
hidden string
226+
sameBlock bool
227+
}
228+
229+
// column answers where this side's gutter meets the rule, and whether the line
230+
// GOES ON past it: the hidden row draws the same gutter, or belongs to the
231+
// same block. The second half is what a tool block needs -- its header row
232+
// carries no gutter, but the body under it is the same block, so a rule
233+
// between them cuts one line, not two.
234+
func (c gutterCut) column() (int, bool) {
235+
col, ok := gutterColumn(c.row)
236+
if !ok {
237+
return 0, false
238+
}
239+
if c.sameBlock {
240+
return col, true
241+
}
242+
other, ok := gutterColumn(c.hidden)
243+
return col, ok && other == col
244+
}
245+
246+
// joinRule marks a rule where a block's vertical gutter is CUT by it. A stroke
247+
// says the line goes on past the rule on that side and nothing else: up when
248+
// the gutter above continues under it, down when the gutter below comes from
249+
// above it, ┼ where one line does both. A gutter that merely begins or ends
250+
// against the rule is a whole line already, and a junction there would branch
251+
// to a block that is not there.
252+
func joinRule(rule string, above, below gutterCut) string {
253+
up, upOK := above.column()
254+
down, downOK := below.column()
255+
w := displayWidth(rule)
256+
upOK = upOK && up < w
257+
downOK = downOK && down < w
258+
switch {
259+
case upOK && downOK && up == down:
260+
return ldrender.OverlayColumn(rule, up, "┼")
261+
case upOK && downOK:
262+
return ldrender.OverlayColumn(ldrender.OverlayColumn(rule, up, "┴"), down, "┬")
263+
case upOK:
264+
return ldrender.OverlayColumn(rule, up, "┴")
265+
case downOK:
266+
return ldrender.OverlayColumn(rule, down, "┬")
267+
}
268+
return rule
204269
}
205270

206271
// gutterColumn is the column of a row's leading vertical rule, if it has one.

‎skills/figaro/reference/ui-stream.md‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,16 +624,28 @@ conversation with a rule at the bottom. Both rules are crossed by the gutters
624624
that run down the left of thinking, steering and tool blocks, and a **junction**
625625
(`┬` above, `┴` below) is drawn where one is.
626626

627-
A junction says ONE thing: *this line goes on past the rule*. So it is drawn
628-
only when the gutter is on both sides of it: the row the reader can see, and
629-
the row the chrome covers. The first row of a block sitting under the sticky
630-
header joins nothing (the line begins there); the last row of a block sitting
631-
against the floor joins nothing either (the line ends there); scroll one row
632-
and the same rule grows its junction, because now the block really is cut.
627+
A stroke says ONE thing: *this line goes on past the rule*. Each side is asked
628+
separately, so all four glyphs happen:
629+
630+
| | the gutter above is cut | it is not |
631+
|---|---|---|
632+
| **the gutter below is cut** | `┼` | `┬` |
633+
| **it is not** | `┴` | `─` |
634+
635+
"Cut" is a question about the BLOCK, not about the glyph on the next row: the
636+
row the reader sees carries a gutter, and the row the chrome hides just beyond
637+
it either draws the same gutter or belongs to the same block. That second half
638+
is what a tool needs. Its header row (`✓ $ grep …`) carries no gutter while its
639+
body does, so a rule between them cuts one line and wears `┬`; a rule above a
640+
tool that BEGINS under it wears nothing. A quoted question works the same way
641+
from the other side: one line of it ends against the rule and draws nothing,
642+
several lines run under the header and draw `┴`, and if the row below is cut
643+
too the two strokes meet as `┼`.
633644

634645
`joinRule` in `transcript_sticky.go` is the whole of it, and both the header
635646
and the floor call it, which is why they cannot disagree again: the floor used
636-
to draw no junctions at all while the header drew one unconditionally.
647+
to draw no junctions at all while the header drew one unconditionally. The
648+
floor never wears a down stroke; there is nothing under it but the status bar.
637649

638650
## Steering: messages mid-turn
639651

0 commit comments

Comments
 (0)