Compare commits

...
3 Commits
Author SHA1 Message Date
conductor cf7310969d chore(release): auto-bump version to 0.338.0 — earned by # (minor: converged release (push-to-master bump))
deploy / deploy (push) Skipped
Auto-version and Release / release (push) Successful in 21s
CI Pipeline / best-practices (push) Successful in 18s
CI Pipeline / documentation (push) Successful in 12s
CI Pipeline / rust-surface (push) Failing after 31s
TUI Release / release (push) Successful in 42s
CI Pipeline / gitea-offline (push) Successful in 25s
CI Pipeline / go-surface (push) Failing after 1m0s
CI Pipeline / lint (push) Failing after 1m13s
CI Pipeline / type-check (push) Failing after 1m8s
CI Pipeline / canary (push) Failing after 4m19s
CI Pipeline / unit-tests (push) Failing after 48m9s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 5s
Promotion record: (unknown)
2026-10-22 19:58:11 +01:00
Nick 57b12dccf3 feat(tui-scrollback): EACH pane scrolls individually — chat transcript + tasks panel, keyboard + wheel (#2535, D50-1 priority, red-first)
CI Pipeline / best-practices (push) Canceled after 0s
CI Pipeline / documentation (push) Canceled after 0s
CI Pipeline / type-check (push) Canceled after 0s
CI Pipeline / gitea-offline (push) Canceled after 0s
CI Pipeline / rust-surface (push) Canceled after 28s
CI Pipeline / required-checks (push) Canceled after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Canceled after 0s
Auto-version and Release / release (push) Canceled after 38s
CI Pipeline / go-surface (push) Canceled after 32s
CI Pipeline / canary (push) Canceled after 32s
CI Pipeline / lint (push) Canceled after 36s
CI Pipeline / unit-tests (push) Canceled after 36s
deploy / deploy (push) Canceled after 41s
The tasks pane gets its OWN scroll state (an offset in rendered panel
rows from the newest end; 0 rests on the cursor-follow layout; the
stay-put law re-anchors by construction), beside the chat pane's
existing transcript window. Keyboard routes by FOCUS (ctrl+up/down
line-wise, pgup/pgdown page-wise — the focused pane scrolls, never
both), and the mouse wheel routes by the PANE UNDER THE POINTER
(wheel-up past the oldest held chat row grows the bridge's poll
window for the deeper page). The individual-state seam rides the
delegate chain (ChatScrollOffset).
2026-10-22 19:57:38 +01:00
Nick 9bd088e87f feat(tui-presentation): the owner's live-boot rulings — word-first snake, everything-boxed, flat left edge (D50 evidence, red-first)
Four live rulings folded: (1) the ACTIVITY WORD leads the strip —
rainbow with a snake-like brightness sweep through the word and back —
and the runic marks play AFTER it; (2) EVERY element boxes as standard
— lists and headings get their own rounded boxes in the bubble family
(speaker-coloured borders), with a blank line between bullets for
readability — superseding the naked-rail breakout; (3) every box
renders FLUSH LEFT, user and rune alike, one flat conversation edge —
the stamp-hang indent defect is structurally gone, the stamp trails
the box's bottom-right corner; (4) no indent creep (importance
prefixes left-align too). The D43 stamp-side pins and the
blocks/presentation pins re-pinned to the live law; the spec doc
carries the rulings.
2026-10-22 19:57:38 +01:00
19 changed files with 492 additions and 180 deletions

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+31 -37
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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
}
+144
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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()
}
+3
View File
@@ -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() }
+3
View File
@@ -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() }
+43
View File
@@ -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() }
+16
View File
@@ -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) {