Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf7310969d | ||
|
|
57b12dccf3 | ||
|
|
9bd088e87f |
No files matched your search
@@ -1,9 +1,9 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.337.0**
|
||||
**Current release: v0.338.0**
|
||||
|
||||
<!-- highlights:start -->
|
||||
## Highlights — from the latest release (v0.337.0)
|
||||
## Highlights — from the latest release (v0.338.0)
|
||||
|
||||
- Plain-English jargon dictionary covers the v0.329 vocabulary, plus catch-all sentence simplification pass.
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ Each activity has its OWN colour and its OWN animation pattern. The runic animat
|
||||
Runic marks may randomise colour per rune within the activity's palette (owner ruling: "could
|
||||
have random colours per rune"). The activity WORD animates while active.
|
||||
|
||||
### A2a. Animation order (owner live ruling 2026-10-22, D50 boot)
|
||||
The ACTIVITY WORD comes FIRST — it animates in rainbow colours with a snake-like sweep
|
||||
through the word and back — and the runic animation plays AFTER the word. Word leads,
|
||||
runes follow.
|
||||
|
||||
### A3. The word itself
|
||||
The activity word randomises a rainbow colour while active (owner ruling #2527, subsumed here).
|
||||
|
||||
@@ -52,6 +57,18 @@ and either retired or made honest.
|
||||
|
||||
## Part B — THE ANSWER PRESENTATION SYSTEM (chatpane)
|
||||
|
||||
### B0. Everything gets a box (owner live ruling 2026-10-22, D50 boot)
|
||||
EVERY element is boxed as standard — tables, bullet lists, headings,
|
||||
everything — each element type its own box shape (table box, list box,
|
||||
heading box) in the bubble family's rounded framing, borders in the
|
||||
speaker's colour. Nothing floats naked after a bubble. This ruling
|
||||
SUPERSEDES the B2 "breakout, no bubble" wording for lists/headings:
|
||||
they break out of the SPEECH bubble into their OWN boxes. Boxes render
|
||||
FLUSH LEFT (ruling: no indent creep — one flat left edge for the whole
|
||||
conversation, user and rune alike) with the timestamp TRAILING the
|
||||
box's bottom-right corner. Bullet lists carry a blank line between
|
||||
items for readability.
|
||||
|
||||
### B1. Proper markdown, zero model-side cost
|
||||
Parse the model's OWN markdown into blocks once per completed answer (TUI-side, after the turn).
|
||||
No prompt changes, no extra tokens, no context cost. Bounded dialect renderer (~6 block types),
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.337.0"
|
||||
version = "0.338.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This is the main entry point for the Rune program.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "0.337.0"
|
||||
__version__ = "0.338.0"
|
||||
|
||||
# One build identity (handoff 2026-09-10 §4): the semantic version above is
|
||||
# the repo-level truth (auto-bumped at merge); the DEPLOYED revision (exact
|
||||
|
||||
@@ -233,7 +233,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.337.0"' in rune_version,
|
||||
'__version__ = "0.338.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -8,7 +8,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.337.0
|
||||
Version: v0.338.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -2047,7 +2047,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.337.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.338.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
+73
-25
@@ -228,11 +228,11 @@ func (p *Pane) answerPresentation(m Message, styled bool, ts string, w int, righ
|
||||
out = append(out, "")
|
||||
case "heading":
|
||||
flush()
|
||||
out = append(out, headingLines(seg.text, w, styled, assistantStyle)...)
|
||||
out = append(out, headingBoxLines(seg.text, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
|
||||
out = append(out, "")
|
||||
case "list":
|
||||
flush()
|
||||
out = append(out, railLines(seg, w, styled, assistantStyle)...)
|
||||
out = append(out, listBoxLines(seg, w, styled, assistantStyle, roleBorderStyle(m.Role))...)
|
||||
out = append(out, "")
|
||||
default:
|
||||
run = append(run, seg)
|
||||
@@ -331,48 +331,96 @@ func sumInt(xs []int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// railLines renders one list block as the RAIL presentation (B2.3,
|
||||
// outside the bubble): a left rail in the speaker's colour, hanging
|
||||
// indent, genuine bullets dotted, numbered steps numbered from the
|
||||
// listBoxLines renders one list block as the LIST BOX (owner live
|
||||
// ruling 2026-10-22: EVERYTHING gets a box — no element floats naked
|
||||
// after a bubble): the rail rides INSIDE a rounded box in the bubble
|
||||
// family, one blank line between items for readability (the same
|
||||
// ruling), genuine bullets dotted, numbered steps numbered from the
|
||||
// block's own start.
|
||||
func railLines(seg mdBlock, w int, styled bool, st lipgloss.Style) []string {
|
||||
var out []string
|
||||
func listBoxLines(seg mdBlock, w int, styled bool, st, border lipgloss.Style) []string {
|
||||
inner := max(w-4, 4)
|
||||
var rows []string
|
||||
num := seg.start
|
||||
for _, item := range seg.items {
|
||||
for i, item := range seg.items {
|
||||
if i > 0 {
|
||||
rows = append(rows, "") // a blank line between bullets (the ruling)
|
||||
}
|
||||
marker := bulletGlyph
|
||||
if seg.ordered {
|
||||
marker = fmt.Sprintf("%d.", num)
|
||||
num++
|
||||
}
|
||||
body := wrap(item, max(w-6, 1))
|
||||
for i, l := range body {
|
||||
body := wrap(item, max(inner-4, 1))
|
||||
for j, l := range body {
|
||||
lead := " "
|
||||
if i == 0 {
|
||||
if j == 0 {
|
||||
lead = marker + " "
|
||||
} else if seg.ordered {
|
||||
lead = " "
|
||||
}
|
||||
line := lead + l
|
||||
if styled {
|
||||
out = append(out, assistantRailStyle.Render("▌ ")+st.Render(line))
|
||||
rows = append(rows, assistantRailStyle.Render("▌ ")+st.Render(line))
|
||||
continue
|
||||
}
|
||||
out = append(out, "▌ "+line)
|
||||
rows = append(rows, "▌ "+line)
|
||||
}
|
||||
}
|
||||
return boxAround(rows, w, styled, border)
|
||||
}
|
||||
|
||||
// headingBoxLines renders one heading block (B2.5 + the live ruling:
|
||||
// everything boxed): a rounded box carrying the heading text and its
|
||||
// speaker-coloured underline.
|
||||
func headingBoxLines(text string, w int, styled bool, st, border lipgloss.Style) []string {
|
||||
inner := max(w-4, 4)
|
||||
line := clip(text, inner)
|
||||
underline := strings.Repeat("─", min(len([]rune(text)), inner))
|
||||
var rows []string
|
||||
if styled {
|
||||
rows = []string{st.Bold(true).Render(line), st.Render(underline)}
|
||||
} else {
|
||||
rows = []string{line, underline}
|
||||
}
|
||||
return boxAround(rows, w, styled, border)
|
||||
}
|
||||
|
||||
// boxAround draws the rounded box of the bubble family around
|
||||
// pre-rendered rows (the everything-boxed ruling's consistent
|
||||
// framing): the border carries the SPEAKER's colour.
|
||||
func boxAround(rows []string, w int, styled bool, border lipgloss.Style) []string {
|
||||
bw := 4
|
||||
for _, r := range rows {
|
||||
if n := len([]rune(semantic_stripANSI(r))) + 4; n > bw {
|
||||
bw = n
|
||||
}
|
||||
}
|
||||
bw = min(bw, max(w, 4))
|
||||
inner := max(bw-4, 1)
|
||||
top := "╭" + strings.Repeat("─", bw-2) + "╮"
|
||||
bot := "╰" + strings.Repeat("─", bw-2) + "╯"
|
||||
emit := func(l string) string {
|
||||
if styled {
|
||||
return border.Render(l)
|
||||
}
|
||||
return l
|
||||
}
|
||||
out := []string{emit(top)}
|
||||
for _, r := range rows {
|
||||
plain := semantic_stripANSI(r)
|
||||
clipN := max(inner-len([]rune(plain)), 0)
|
||||
if styled {
|
||||
pad := strings.Repeat(" ", clipN)
|
||||
out = append(out, emit("│ ")+r+pad+emit(" │"))
|
||||
continue
|
||||
}
|
||||
out = append(out, "│ "+pad(clip(r, inner), inner)+" │")
|
||||
}
|
||||
out = append(out, emit(bot))
|
||||
return out
|
||||
}
|
||||
|
||||
// headingLines renders one heading block (B2.5): full-width, the
|
||||
// speaker-coloured underline, NO bubble.
|
||||
func headingLines(text string, w int, styled bool, st lipgloss.Style) []string {
|
||||
line := clip(text, w)
|
||||
underline := strings.Repeat("─", min(len([]rune(text)), max(w, 0)))
|
||||
if styled {
|
||||
return []string{st.Bold(true).Render(line), st.Render(underline)}
|
||||
}
|
||||
return []string{line, underline}
|
||||
}
|
||||
// semantic_stripANSI is the package's ANSI strip (the box math counts
|
||||
// display columns only).
|
||||
func semantic_stripANSI(s string) string { return stripANSI(s) }
|
||||
|
||||
var assistantHeaderStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#1E1E28")).Foreground(lipgloss.Color("#7EE787")).Bold(true)
|
||||
|
||||
+20
-11
@@ -103,14 +103,18 @@ func TestListRidesRailOutsideBubble(t *testing.T) {
|
||||
if strings.Contains(plain, "• The steps") {
|
||||
t.Fatalf("a prose sentence must never render as a bullet:\n%s", plain)
|
||||
}
|
||||
// the bubble holds ONLY the prose: no bullet line sits inside a
|
||||
// │-bordered row
|
||||
// the LIST BOX is its own box (the everything-boxed ruling): the
|
||||
// rail + bullets ride inside a rounded box DISTINCT from the
|
||||
// speech bubble — its own top border, never the bubble's
|
||||
tops := 0
|
||||
for _, l := range lines {
|
||||
s := semantic.StripANSI(l)
|
||||
if strings.HasPrefix(s, "│") && strings.Contains(s, "•") {
|
||||
t.Fatalf("list items must live OUTSIDE the bubble:\n%s", s)
|
||||
if strings.HasPrefix(semantic.StripANSI(l), "╭") {
|
||||
tops++
|
||||
}
|
||||
}
|
||||
if tops != 3 {
|
||||
t.Fatalf("the list needs its OWN box between the prose bubbles (3 tops, got %d):\n%s", tops, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberedStepsNumberedOnTheRail(t *testing.T) {
|
||||
@@ -138,14 +142,17 @@ func TestHeadingFullWidthNoBubble(t *testing.T) {
|
||||
if !strings.Contains(styled, "───") && !strings.Contains(plain, "───") {
|
||||
t.Fatalf("the heading must carry its underline:\n%s", plain)
|
||||
}
|
||||
// NO bubble for the heading: the heading line itself never sits
|
||||
// inside │ borders
|
||||
// the heading rides its OWN box (the everything-boxed ruling):
|
||||
// text + underline inside a rounded box, never naked
|
||||
tops := 0
|
||||
for _, l := range lines {
|
||||
s := semantic.StripANSI(l)
|
||||
if strings.HasPrefix(s, "│") && strings.Contains(s, "The Wave Plan") {
|
||||
t.Fatalf("a heading never renders inside the bubble:\n%s", s)
|
||||
if strings.HasPrefix(semantic.StripANSI(l), "╭") {
|
||||
tops++
|
||||
}
|
||||
}
|
||||
if tops != 2 {
|
||||
t.Fatalf("the heading needs its OWN box beside the speech bubble's one (2 tops, got %d):\n%s", tops, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongAnswerNeverBreaksBubble(t *testing.T) {
|
||||
@@ -210,7 +217,9 @@ func TestOneStampPerAnswer(t *testing.T) {
|
||||
lines := p.answerPresentation(m, true, "10:00", 60, false)
|
||||
stamps := 0
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(semantic.StripANSI(l), "10:00") {
|
||||
// the LIVE ruling: the stamp TRAILS the box's bottom-right
|
||||
trimmed := strings.TrimRight(semantic.StripANSI(l), " ")
|
||||
if strings.HasSuffix(trimmed, "10:00") {
|
||||
stamps++
|
||||
}
|
||||
}
|
||||
|
||||
+20
-58
@@ -567,7 +567,6 @@ func (p *Pane) renderLines(w int, styled bool) ([]string, []ThinkingMark, []Expa
|
||||
// its role's side (D43.4).
|
||||
func (p *Pane) bubble(m Message, w int, styled bool) []string {
|
||||
if m.Importance >= ImportanceHigh {
|
||||
right := rightSide(m.Role)
|
||||
body := p.bubbleBody(m, max(w-2, 1), styled)
|
||||
out := make([]string, 0, len(body))
|
||||
for _, l := range body {
|
||||
@@ -577,13 +576,6 @@ func (p *Pane) bubble(m Message, w int, styled bool) []string {
|
||||
} else {
|
||||
line = ImportanceGlyph + " " + l
|
||||
}
|
||||
if right {
|
||||
if styled {
|
||||
line = padLeftANSI(line, w)
|
||||
} else {
|
||||
line = padLeft(line, w)
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
@@ -846,12 +838,19 @@ func (p *Pane) bubbleBox(styled bool, border, content, leadStyle lipgloss.Style,
|
||||
// markdown projection: paragraphs, bullets with hanging indent) — the
|
||||
// markdown structure must survive the box verbatim, so the lines are
|
||||
// never re-wrapped.
|
||||
//
|
||||
// OWNER LIVE RULING 2026-10-22 (the alignment defect + no indent
|
||||
// creep): EVERY box renders FLUSH LEFT — column 0 of the pane content
|
||||
// — for user and rune alike, the whole conversation one flat left
|
||||
// edge (the old stamp-hang indent pushed later answers inward). The
|
||||
// timestamp rides AFTER the box's bottom-right corner, never pushing
|
||||
// the box off the edge — and never past the pane width.
|
||||
func (p *Pane) bubbleBoxLines(styled bool, border, content, leadStyle lipgloss.Style, lead string, bodyLines []string, ts string, w int, right bool) []string {
|
||||
hang := 0
|
||||
cap := max(w, 4)
|
||||
if ts != "" {
|
||||
hang = 1 + len([]rune(ts))
|
||||
// the trailing stamp's gutter stays inside the pane width
|
||||
cap = max(w-1-len([]rune(ts)), 4)
|
||||
}
|
||||
cap := max(w-hang, 4)
|
||||
var raw []string
|
||||
for i, l := range bodyLines {
|
||||
l = clip(l, max(cap-4, 1))
|
||||
@@ -883,58 +882,21 @@ func (p *Pane) bubbleBoxLines(styled bool, border, content, leadStyle lipgloss.S
|
||||
}
|
||||
out = append(out, "│ "+pad(clip(l, inner), inner)+" │")
|
||||
}
|
||||
// the bottom border row carries the stamp in the FAR-LEFT gutter
|
||||
// (D43 addendum 6 item 2: always column 0, same position regardless
|
||||
// of sender — the old corner-adjacent placement is superseded).
|
||||
var bottom string
|
||||
// the stamp trails the box's bottom-right corner (the live
|
||||
// ruling): the box owns the flat left edge, the stamp never
|
||||
// indents anything.
|
||||
bottom := bot
|
||||
if styled {
|
||||
bb := border.Render(bot)
|
||||
if ts != "" {
|
||||
bottom = tsStyle.Render(ts) + " " + bb
|
||||
bottom = border.Render(bot)
|
||||
}
|
||||
if ts != "" {
|
||||
if styled {
|
||||
bottom = bottom + " " + tsStyle.Render(ts)
|
||||
} else {
|
||||
bottom = bb
|
||||
}
|
||||
} else {
|
||||
if ts != "" {
|
||||
bottom = ts + " " + bot
|
||||
} else {
|
||||
bottom = bot
|
||||
bottom = bottom + " " + ts
|
||||
}
|
||||
}
|
||||
out = append(out, bottom)
|
||||
if right {
|
||||
// right-hug: the box's right edge stays at w; the bottom row
|
||||
// holds the stamp at column 0 and the box right-aligned in the
|
||||
// post-gutter room (its right corner reaches w).
|
||||
for i, l := range out {
|
||||
if ts != "" && i == len(out)-1 {
|
||||
var bb string
|
||||
if styled {
|
||||
bb = border.Render(bot)
|
||||
} else {
|
||||
bb = bot
|
||||
}
|
||||
out[i] = padLeftANSI(bb, w-hang)
|
||||
if styled {
|
||||
out[i] = tsStyle.Render(ts) + " " + out[i]
|
||||
} else {
|
||||
out[i] = ts + " " + out[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[i] = padLeftANSI(l, w)
|
||||
}
|
||||
} else if ts != "" {
|
||||
// left-hug: the whole box indents by the stamp's gutter so the
|
||||
// stamp fits at column 0 left of the bubble; the bottom row
|
||||
// already starts with the stamp at 0.
|
||||
for i := range out {
|
||||
if i == len(out)-1 {
|
||||
continue
|
||||
}
|
||||
out[i] = strings.Repeat(" ", hang) + out[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -898,12 +898,14 @@ func TestUserBubbleTimestampOppositeSide(t *testing.T) {
|
||||
lines := p.Render(w, 30, false)
|
||||
found := false
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, stampStr+" ╰") {
|
||||
// the LIVE ruling (2026-10-22): the box owns the flat left
|
||||
// edge; the stamp TRAILS the bottom-right corner
|
||||
if strings.HasPrefix(l, "╰") && strings.HasSuffix(strings.TrimRight(l, " "), " "+stampStr) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("w=%d: stamp must LEAD the bubble's bottom row (D43.3):\n%s", w, strings.Join(lines, "\n"))
|
||||
t.Fatalf("w=%d: stamp must TRAIL the box's bottom-right corner (the flat-left-edge ruling):\n%s", w, strings.Join(lines, "\n"))
|
||||
}
|
||||
for i, l := range lines {
|
||||
if n := len([]rune(l)); n > w {
|
||||
@@ -965,9 +967,11 @@ func TestUserBubbleFullBoxAlignment(t *testing.T) {
|
||||
w, i, col, cornerCol, rows)
|
||||
}
|
||||
}
|
||||
bottom := []rune(rows[len(rows)-1])
|
||||
if string(bottom[:5]) != stampStr {
|
||||
t.Errorf("w=%d: the stamp must lead the bottom row (D43.3): %q", w, rows[len(rows)-1])
|
||||
bottom := rows[len(rows)-1]
|
||||
// the LIVE ruling: the stamp trails the box's bottom-right
|
||||
// corner; the box keeps the flat left edge
|
||||
if !strings.HasPrefix(bottom, "╰") || !strings.HasSuffix(bottom, " "+stampStr) {
|
||||
t.Errorf("w=%d: the stamp must trail the bottom row's right corner: %q", w, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,7 +1032,10 @@ func containsWhole(s, word string) bool {
|
||||
// re-land): EVERY stamp-bearing row starts its timestamp at column 0,
|
||||
// the same position regardless of sender — the old opposite-side /
|
||||
// corner-adjacent placements are superseded.
|
||||
func TestTimestampsFarLeftGutterBothSenders(t *testing.T) {
|
||||
func TestTimestampsTrailBothSendersBoxes(t *testing.T) {
|
||||
// re-pinned to the LIVE ruling (2026-10-22): both senders' boxes
|
||||
// render FLUSH LEFT — one flat conversation edge, no indent creep —
|
||||
// and each stamp trails its own box's bottom-right corner.
|
||||
if rtl {
|
||||
t.Skip("stamp side pinned for the LTR machine")
|
||||
}
|
||||
@@ -1038,41 +1045,28 @@ func TestTimestampsFarLeftGutterBothSenders(t *testing.T) {
|
||||
Content: "user side line"})
|
||||
p.Append(Message{ID: "a1", Role: RoleAssistant, Ts: "2026-10-18T12:01:00Z",
|
||||
Content: "rune side line"})
|
||||
type stampRow struct {
|
||||
col int
|
||||
boxCol int
|
||||
}
|
||||
var rows []stampRow
|
||||
for _, l := range p.Render(w, 12, false) {
|
||||
trimmed := strings.TrimLeft(l, " ")
|
||||
if len(trimmed) < 5 || trimmed[2] != ':' {
|
||||
continue
|
||||
lines := p.Render(w, 12, false)
|
||||
stamps := 0
|
||||
for _, l := range lines {
|
||||
trimmed := strings.TrimRight(l, " ")
|
||||
// a trailing HH:MM stamp: "╰───╯ 13:01"
|
||||
ok := strings.HasPrefix(l, "\u2570") && len(trimmed) >= 6
|
||||
if ok {
|
||||
tail := []rune(trimmed)[len([]rune(trimmed))-5:]
|
||||
ok = len(tail) == 5 && tail[2] == ':' && tail[0] >= '0' && tail[0] <= '9'
|
||||
}
|
||||
if _, err := time.Parse("15:04", trimmed[:5]); err != nil {
|
||||
continue
|
||||
if ok {
|
||||
stamps++
|
||||
}
|
||||
col := strings.Index(l, trimmed[:5])
|
||||
rest := l[col+5:]
|
||||
boxCol := -1
|
||||
for i, r := range rest {
|
||||
if r == '╰' {
|
||||
boxCol = col + 5 + i
|
||||
break
|
||||
}
|
||||
}
|
||||
rows = append(rows, stampRow{col: col, boxCol: boxCol})
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("w=%d: expected both senders' stamps, found %d:\n%s",
|
||||
w, len(rows), strings.Join(p.Render(w, 12, false), "\n"))
|
||||
if stamps != 2 {
|
||||
t.Fatalf("w=%d: both senders' stamps must trail their boxes (found %d):\n%s",
|
||||
w, stamps, strings.Join(lines, "\n"))
|
||||
}
|
||||
if rows[0].col != rows[1].col {
|
||||
t.Fatalf("w=%d: stamps sit at DIFFERENT columns (%d vs %d) — the corner placement persists:\n%s",
|
||||
w, rows[0].col, rows[1].col, strings.Join(p.Render(w, 12, false), "\n"))
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.boxCol >= 0 && r.boxCol < r.col {
|
||||
t.Fatalf("w=%d: bubble renders LEFT of the stamp — not the far-left gutter", w)
|
||||
// the flat left edge: every box top row starts at column 0
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, "╭") && !strings.HasPrefix(l, "╭") {
|
||||
t.Fatalf("w=%d: a box indents off the flat left edge:\n%q", w, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@ func (p *Pane) richBubble(m Message, styled bool, st lipgloss.Style, prefix, ts
|
||||
}
|
||||
continue
|
||||
case seg.kind == "heading":
|
||||
for _, l := range headingLines(seg.text, w, styled, st) {
|
||||
for _, l := range headingBoxLines(seg.text, w, styled, st, roleBorderStyle(m.Role)) {
|
||||
emit(l)
|
||||
}
|
||||
continue
|
||||
|
||||
+75
-19
@@ -2277,32 +2277,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
// chat-transcript scrollback (the /history law's live leg):
|
||||
// ctrl+up/ctrl+down walk the scroll window line-wise,
|
||||
// pgup/pgdown page-wise — control-page keys, reachable while
|
||||
// composing (the ctrl+t precedent); a scroll that CANNOT
|
||||
// move (the oldest held row, or nothing older held) grows
|
||||
// the bridge's poll window for the deeper page.
|
||||
// SCROLLBACK (#2535, D50-1: each pane scrolls INDIVIDUALLY
|
||||
// with its own scroll state): ctrl+up/ctrl+down walk the
|
||||
// FOCUSED pane's scroll window line-wise, pgup/pgdown
|
||||
// page-wise — the chat pane's transcript or the tasks
|
||||
// pane's panel, never both. A chat scroll that CANNOT move
|
||||
// grows the bridge's poll window for the deeper page.
|
||||
switch msg.String() {
|
||||
case "ctrl+up":
|
||||
if !m.face.ScrollChatUp(1) {
|
||||
case "ctrl+up", "pgup":
|
||||
if m.face.Snapshot().Focus == semantic.FocusTasks {
|
||||
m.face.TasksScrollUp(pageStepOf(msg.String()))
|
||||
return m, nil
|
||||
}
|
||||
moved := false
|
||||
if msg.String() == "pgup" {
|
||||
moved = m.face.PageChatUp()
|
||||
} else {
|
||||
moved = m.face.ScrollChatUp(1)
|
||||
}
|
||||
if !moved {
|
||||
m.growChatHistory()
|
||||
}
|
||||
m.face.TouchScroll() // the scrollbar layer lights (D43)
|
||||
return m, nil
|
||||
case "ctrl+down":
|
||||
m.face.ScrollChatDown(1)
|
||||
m.face.TouchScroll() // the scrollbar layer lights (D43)
|
||||
return m, nil
|
||||
case "pgup":
|
||||
if !m.face.PageChatUp() {
|
||||
m.growChatHistory()
|
||||
case "ctrl+down", "pgdown":
|
||||
if m.face.Snapshot().Focus == semantic.FocusTasks {
|
||||
m.face.TasksScrollDown(pageStepOf(msg.String()))
|
||||
return m, nil
|
||||
}
|
||||
if msg.String() == "pgdown" {
|
||||
m.face.PageChatDown()
|
||||
} else {
|
||||
m.face.ScrollChatDown(1)
|
||||
}
|
||||
m.face.TouchScroll() // the scrollbar layer lights (D43)
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.face.PageChatDown()
|
||||
return m, nil
|
||||
}
|
||||
// input-territory editing and history keys (the old-TUI
|
||||
// HistoryInput contract, adopted 2026-10-18 + owner defect
|
||||
@@ -2504,6 +2513,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.face.Click(msg.X, msg.Y)
|
||||
m.syncFocusSignage(focusBefore)
|
||||
return m, nil
|
||||
case tea.MouseWheelMsg:
|
||||
// SCROLLBACK (#2535, D50-1): the wheel scrolls the pane under
|
||||
// the pointer — chat transcript or tasks panel, each its own
|
||||
// scroll state. A chat wheel past the oldest held row grows
|
||||
// the bridge's poll window for the deeper page.
|
||||
up := msg.Button == tea.MouseWheelUp
|
||||
down := msg.Button == tea.MouseWheelDown
|
||||
if !up && !down {
|
||||
return m, nil
|
||||
}
|
||||
for _, r := range m.face.Render().Regions {
|
||||
if msg.X < r.X || msg.X >= r.X+r.W || msg.Y < r.Y || msg.Y >= r.Y+r.H {
|
||||
continue
|
||||
}
|
||||
switch r.ID {
|
||||
case "region.chat":
|
||||
moved := false
|
||||
if up {
|
||||
moved = m.face.ScrollChatUp(3)
|
||||
if !moved {
|
||||
m.growChatHistory()
|
||||
}
|
||||
} else {
|
||||
moved = m.face.ScrollChatDown(3)
|
||||
}
|
||||
if moved {
|
||||
m.face.TouchScroll()
|
||||
}
|
||||
case "region.tasks", "region.tasks.tree":
|
||||
if up {
|
||||
m.face.TasksScrollUp(3)
|
||||
} else {
|
||||
m.face.TasksScrollDown(3)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case tea.MouseMotionMsg:
|
||||
// the #2532 drag-select's motion leg: while the left button
|
||||
// holds and the drag started in a text pane, the head follows
|
||||
@@ -3306,7 +3353,7 @@ func main() {
|
||||
printBuildID(os.Stdout)
|
||||
return
|
||||
}
|
||||
wireOSCDetection() // the version hyperlink's real-terminal detection
|
||||
wireOSCDetection() // the version hyperlink's real-terminal detection
|
||||
wireOSC52Detection() // the selection copy's real-terminal detection (#2532)
|
||||
bootCheck = update.DefaultCheck
|
||||
// D38: the ledger section reads the task markdown ledger through
|
||||
@@ -3341,3 +3388,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// pageStepOf maps a scroll key spelling to its step (line keys 1,
|
||||
// page keys one visible page — the chat pane's pageStep shape).
|
||||
func pageStepOf(spelling string) int {
|
||||
if strings.HasPrefix(spelling, "pg") {
|
||||
return 20
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// scrollback_test.go — THE SCROLLBACK law (#2535, D50-1 priority):
|
||||
// EACH pane scrolls individually with its own scroll state — the chat
|
||||
// transcript (keyboard + wheel) AND the tasks panel (keyboard + wheel)
|
||||
// — stay-put while content changes, and the tasks pane never scrolls
|
||||
// the chat by accident.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
|
||||
"rune.dev/rune/tui/chatpane"
|
||||
"rune.dev/rune/tui/semantic"
|
||||
)
|
||||
|
||||
func scrollModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := sized(t, initialModel(), 80, 24)
|
||||
// enough chat rows to overflow the pane
|
||||
for i := 0; i < 40; i++ {
|
||||
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleUser,
|
||||
Content: strings.Repeat("row ", 8) + itoa(i), Ts: "2026-10-22T10:00:00Z"})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func pgUpPress() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyPgUp} }
|
||||
|
||||
func pgDownPress() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyPgDown} }
|
||||
|
||||
func TestChatScrollsIndividually(t *testing.T) {
|
||||
m := scrollModel(t)
|
||||
m.face.Render() // the scroll geometry anchors on the last render
|
||||
if !m.face.ScrollChatUp(5) {
|
||||
t.Fatal("the chat pane must scroll toward the older end")
|
||||
}
|
||||
frame := semantic.StripANSI(m.face.Render().Content)
|
||||
if !strings.Contains(frame, "more") {
|
||||
t.Fatalf("a scrolled chat pane shows its ↑ indicator:\n%s", frame)
|
||||
}
|
||||
if !m.face.ScrollChatDown(5) {
|
||||
t.Fatal("the chat pane must scroll back down")
|
||||
}
|
||||
if m.face.ScrollChatDown(1) {
|
||||
t.Fatal("at rest, further downs move nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksScrollsIndividually(t *testing.T) {
|
||||
m := scrollModel(t)
|
||||
// one scroll up must move the tasks panel's offset (its own
|
||||
// scroll state, independent of the chat's)
|
||||
moved := m.face.TasksScrollUp(3)
|
||||
_ = moved
|
||||
off := m.face.TasksScrollOffset()
|
||||
// the pane may or may not overflow at this size; the state must be
|
||||
// a NON-NEGATIVE offset that never moves the chat pane
|
||||
if off < 0 {
|
||||
t.Fatalf("the tasks scroll offset must never go negative: %d", off)
|
||||
}
|
||||
// the tasks pane's scroll is its OWN state: the chat pane never moved
|
||||
if m.face.ChatScrollOffset() != 0 {
|
||||
t.Fatal("the chat pane's scroll state must be untouched by tasks scrolling")
|
||||
}
|
||||
// scrolling down always rests at zero
|
||||
m.face.TasksScrollDown(100)
|
||||
if m.face.TasksScrollOffset() != 0 {
|
||||
t.Fatalf("scrolling down must rest at 0, got %d", m.face.TasksScrollOffset())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWheelRoutesByPaneUnderPointer(t *testing.T) {
|
||||
m := scrollModel(t)
|
||||
// find a cell inside each pane
|
||||
var chatCell, taskCell [2]int
|
||||
for _, r := range m.face.Render().Regions {
|
||||
if r.ID == "region.chat" {
|
||||
chatCell = [2]int{r.X + 2, r.Y + 3}
|
||||
}
|
||||
if r.ID == "region.tasks" {
|
||||
taskCell = [2]int{r.X + 2, r.Y + 3}
|
||||
}
|
||||
}
|
||||
// wheel down over chat: the chat pane scrolls (its own state)
|
||||
next, _ := m.Update(tea.MouseWheelMsg{X: chatCell[0], Y: chatCell[1],
|
||||
Button: tea.MouseWheelDown})
|
||||
m = next.(model)
|
||||
if m.face.ChatScrollOffset() != 0 {
|
||||
t.Fatal("wheel DOWN over chat at rest must not move the offset")
|
||||
}
|
||||
// wheel UP over chat: the offset grows
|
||||
next, _ = m.Update(tea.MouseWheelMsg{X: chatCell[0], Y: chatCell[1],
|
||||
Button: tea.MouseWheelUp})
|
||||
m = next.(model)
|
||||
if m.face.ChatScrollOffset() == 0 {
|
||||
t.Fatal("wheel UP over chat must scroll the chat pane")
|
||||
}
|
||||
// wheel over the tasks pane: the TASKS state moves, the chat's
|
||||
// offset stands still
|
||||
chatOff := m.face.ChatScrollOffset()
|
||||
next, _ = m.Update(tea.MouseWheelMsg{X: taskCell[0], Y: taskCell[1],
|
||||
Button: tea.MouseWheelUp})
|
||||
m = next.(model)
|
||||
if m.face.ChatScrollOffset() != chatOff {
|
||||
t.Fatal("a wheel over the tasks pane must never scroll the chat pane (individual states)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboardScrollRoutesByFocus(t *testing.T) {
|
||||
m := scrollModel(t)
|
||||
// focus the chat pane and pgup: the chat offset grows
|
||||
m.face.Render()
|
||||
next, _ := m.Update(keyPress("1"))
|
||||
m = next.(model)
|
||||
next, _ = m.Update(pgUpPress())
|
||||
m = next.(model)
|
||||
if m.face.ChatScrollOffset() == 0 {
|
||||
t.Fatal("pgup on chat focus must scroll the chat pane")
|
||||
}
|
||||
// focus tasks and pgup: the chat offset stands, the tasks state
|
||||
// is its own
|
||||
chatOff := m.face.ChatScrollOffset()
|
||||
next, _ = m.Update(keyPress("2"))
|
||||
m = next.(model)
|
||||
next, _ = m.Update(pgUpPress())
|
||||
m = next.(model)
|
||||
if m.face.ChatScrollOffset() != chatOff {
|
||||
t.Fatal("pgup on tasks focus must never scroll the chat pane")
|
||||
}
|
||||
}
|
||||
+31
-22
@@ -208,28 +208,51 @@ func (a *ActivityPane) Render(w int, styled bool) string {
|
||||
if a.state == ActivityIdle {
|
||||
wordOut = activityIdleStyle.Render(word)
|
||||
} else {
|
||||
for _, run := range a.rainbow(word) {
|
||||
for _, run := range a.snakeWord(word) {
|
||||
wordOut += run
|
||||
}
|
||||
}
|
||||
line := ""
|
||||
// OWNER LIVE RULING 2026-10-22: the WORD comes FIRST, the
|
||||
// runic marks play AFTER the word (word leads, runes follow).
|
||||
line := wordOut
|
||||
if marks != "" {
|
||||
line = marks + " "
|
||||
line = line + " " + marks
|
||||
}
|
||||
line += wordOut
|
||||
line += strings.Repeat(" ", max(inner-len([]rune(stripANSI(line))), 0))
|
||||
plus := activityPlusStyle.Render("+")
|
||||
return line + strings.Repeat(" ", max(activityStripWidth-len(plus)-2, 0)) + plus
|
||||
}
|
||||
line := ""
|
||||
if marks != "" {
|
||||
line = marks + " "
|
||||
line := word
|
||||
if a.state != ActivityIdle {
|
||||
line = word + " " + marks
|
||||
}
|
||||
line += word
|
||||
line += strings.Repeat(" ", max(inner-len([]rune(line)), 0))
|
||||
return line + strings.Repeat(" ", activityStripWidth-2) + " +"
|
||||
}
|
||||
|
||||
// snakeWord renders the rainbow word with a SNAKE-LIKE SWEEP (owner
|
||||
// live ruling 2026-10-22): a brightness crest travels from the word's
|
||||
// first letter through its last and BACK — the t to the g and home —
|
||||
// while every character keeps its own hue on the wheel. The frame
|
||||
// advances the sweep one cell per tick.
|
||||
func (a *ActivityPane) snakeWord(word string) []string {
|
||||
runes := []rune(word)
|
||||
n := len(runes)
|
||||
sweep := a.frame % max(2*n-2, 1)
|
||||
out := make([]string, 0, n)
|
||||
for i, r := range runes {
|
||||
dist := sweep - i
|
||||
if dist < 0 {
|
||||
dist = -dist
|
||||
}
|
||||
l := 0.62 + 0.1*float64(min(dist, 3))
|
||||
hue := (i*36 + a.frame*12) % 360
|
||||
out = append(out, lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(hslHex(hue, 0.85, l))).Render(string(r)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// marksRun renders the walking runic marks (A2: the runic animations
|
||||
// are KEPT): glyphs from the one shared vocabulary, each coloured
|
||||
// within the activity's palette (per-rune randomisation, deterministic
|
||||
@@ -271,20 +294,6 @@ func activityPalette(s ActivityState) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// rainbow renders the activity word's rainbow colouring (A3): each
|
||||
// character its own hue, the hue wheel advancing with the animation
|
||||
// frame so the word shimmers while active.
|
||||
func (a *ActivityPane) rainbow(word string) []string {
|
||||
runes := []rune(word)
|
||||
out := make([]string, 0, len(runes))
|
||||
for i, r := range runes {
|
||||
hue := (a.frame*24 + i*36) % 360
|
||||
out = append(out, lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(hslHex(hue, 0.85, 0.68))).Render(string(r)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WindowLines renders the + window's content rows at width w (A6): the
|
||||
// full thinking text, the tool calls in flight, one block per full
|
||||
// answer. The caller draws the centered box around it.
|
||||
|
||||
@@ -552,3 +552,11 @@ func (l *Lanes) SetChatStyle(s chatpane.Style) {
|
||||
l.chat.SetStyle(s)
|
||||
}
|
||||
}
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset.
|
||||
func (l *Lanes) ChatScrollOffset() int {
|
||||
if l.chat == nil {
|
||||
return 0
|
||||
}
|
||||
return l.chat.ScrollOffset()
|
||||
}
|
||||
@@ -654,3 +654,6 @@ func (p *Promote) SetWaveFrame(frame int) { p.inner.SetWaveFrame(frame) }
|
||||
// SetStill installs the arbitration state (D43 addendum canon P3): the
|
||||
// rail cluster goes still while the chat thinks.
|
||||
func (p *Promote) SetStill(still bool) { p.inner.SetStill(still) }
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset.
|
||||
func (p *Promote) ChatScrollOffset() int { return p.inner.ChatScrollOffset() }
|
||||
@@ -395,3 +395,6 @@ func (r *Rail) Clone() semantic.Face {
|
||||
|
||||
// SetChatStyle passes the D35 §2 style flip through to the chat lane.
|
||||
func (r *Rail) SetChatStyle(s chatpane.Style) { r.lanes.SetChatStyle(s) }
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset.
|
||||
func (r *Rail) ChatScrollOffset() int { return r.lanes.ChatScrollOffset() }
|
||||
@@ -88,6 +88,15 @@ type Tasks struct {
|
||||
// shown in the work stream while a turn is active; "" hides it.
|
||||
activityLabel string
|
||||
|
||||
// THE SCROLLBACK STATE (#2535, D50-1: each pane scrolls
|
||||
// individually with its own scroll state): an offset in rendered
|
||||
// panel rows from the NEWEST end — 0 rests (cursor-follow), each
|
||||
// line of offset reveals older rows. Stay-put law: content changes
|
||||
// re-anchor by construction (the offset counts from the end).
|
||||
scroll int
|
||||
lastRows int // the last render's row count (the clamp anchor)
|
||||
lastPanelH int // the last render's panel height (the clamp anchor)
|
||||
|
||||
// THE WORKSHOP (D43 addendum 6 item 4 + D44-D): the queue-steer
|
||||
// mode's view state (cursor + one in-flight mutation, the K20
|
||||
// pattern) and the interactive open-task rows' expansion state.
|
||||
@@ -519,6 +528,36 @@ func (f *Tasks) SetActivityLabel(label string) {
|
||||
f.activityLabel = label
|
||||
}
|
||||
|
||||
// ── the SCROLLBACK law (#2535, D50-1) ─────────────────────────────────
|
||||
|
||||
// TasksScrollUp moves the tasks panel's scroll window n rows toward
|
||||
// the older end and reports whether it moved (false = already at the
|
||||
// oldest row, or nothing to scroll).
|
||||
func (f *Tasks) TasksScrollUp(n int) bool {
|
||||
if n <= 0 || f.lastRows == 0 {
|
||||
return false
|
||||
}
|
||||
maxOff := max(f.lastRows-(f.lastPanelH-2), 0)
|
||||
if maxOff == 0 || f.scroll >= maxOff {
|
||||
return false
|
||||
}
|
||||
f.scroll = min(f.scroll+n, maxOff)
|
||||
return true
|
||||
}
|
||||
|
||||
// TasksScrollDown moves the scroll window toward the newest end
|
||||
// (false = already resting).
|
||||
func (f *Tasks) TasksScrollDown(n int) bool {
|
||||
if n <= 0 || f.scroll == 0 {
|
||||
return false
|
||||
}
|
||||
f.scroll = max(f.scroll-n, 0)
|
||||
return true
|
||||
}
|
||||
|
||||
// TasksScrollOffset reports the scroll offset (display state seam).
|
||||
func (f *Tasks) TasksScrollOffset() int { return f.scroll }
|
||||
|
||||
// AmendChatThinking fills the trailing pending thinking bubble with the
|
||||
// completion's surfaced thought text.
|
||||
func (f *Tasks) AmendChatThinking(content string) bool {
|
||||
@@ -750,3 +789,7 @@ func (f *Tasks) SetWaveFrame(frame int) { f.inner.SetWaveFrame(frame) }
|
||||
// SetStill installs the arbitration state (D43 addendum canon P3): the
|
||||
// rail cluster goes still while the chat thinks.
|
||||
func (f *Tasks) SetStill(still bool) { f.inner.SetStill(still) }
|
||||
|
||||
// ChatScrollOffset reports the chat pane's scroll offset (the
|
||||
// scrollback law's individual-state seam).
|
||||
func (f *Tasks) ChatScrollOffset() int { return f.inner.ChatScrollOffset() }
|
||||
@@ -63,6 +63,22 @@ func (f *Tasks) regionFrame(inner semantic.Frame) semantic.Frame {
|
||||
}
|
||||
|
||||
rows, ledgerAt := f.treeRows(pw, railY)
|
||||
f.lastRows, f.lastPanelH = len(rows), railY
|
||||
// THE SCROLLBACK WINDOW (#2535, D50-1): while scrolled, the panel
|
||||
// shows the rows the offset names (stay-put: the offset counts from
|
||||
// the newest end, so content changes re-anchor by construction);
|
||||
// at rest (offset 0) the cursor-follow layout shows whole.
|
||||
if f.scroll > 0 && len(rows) > railY-2 {
|
||||
visible := railY - 2 // the header + one blank row stay pinned
|
||||
maxOff := max(len(rows)-visible, 0)
|
||||
if f.scroll > maxOff {
|
||||
f.scroll = maxOff
|
||||
}
|
||||
end := len(rows) - f.scroll
|
||||
start := max(end-visible, 0)
|
||||
rows = rows[start:end]
|
||||
ledgerAt = nil // a scrolled window declares no row regions
|
||||
}
|
||||
for y := 0; y < railY; y++ {
|
||||
row := ""
|
||||
if y < len(rows) {
|
||||
|
||||
Reference in new issue
Block a user