Files
zero/internal/tui/layout_test.go
KRATOS 63c0e278ff feat(tui): chat + context sidebar redesign, AGENTS panel, animations (#300)
* feat(tui): chat + context sidebar redesign, AGENTS panel, animations, plan-timer fix

Two-column managed-mode layout: the existing chat (scroll engine, reduced width)
on the left + a new right context sidebar — AGENTS (spawned subagents: status
glyph, name, and a live working detail for running ones, with a running/total
count), the live PLAN, and a token readout. The sidebar is suppressed on the
home screen and under overlays/subchat; alt-screen managed mode, wide terminals.

Also: freeze the plan clock while the agent is idle (an in_progress step left
mid-plan when the agent yields stops ticking instead of counting forever), and a
clean-room animation port (phase scroll indicator + a cosine ripple working
line; the spinner was already animated, frozen under ZERO_REDUCED_MOTION).

Reuses the scroll/viewport engine; inline mode unchanged. New unit tests for the
sidebar, plan-clock freeze, and the pure animation helpers; go build/vet/test green.

* feat(swarm): live AGENTS sidebar + un-defer coordination tools

Sidebar AGENTS section now reflects the swarm/team accurately:
- name each member by a short 1-2 word task (not subagent-N)
- a mild, slow cool pulse on live member names (no per-letter flicker)
- members disappear when swarm_status reports them [done]/[failed]
- hide bogus "specialist not found" tool-misroute attempts
- keep the animation tick alive while sidebar agents exist

Plus the deeper fix so a coordinating model stops misrouting swarm calls
to the specialist tool ("specialist swarm_send not found"): the swarm
COORDINATION tools (swarm_send/status/inbox/collect) un-defer once a
swarm is active, while spawn/schedule/handoff stay discoverable via
tool_search. A new DeferralEligible() interface keeps all swarm tools
counting toward DeferThreshold even when exposed, so un-deferring can
never deactivate deferral / force-expose MCP tools in any config.

* feat(tui): polish pass — hierarchy, status line, motion, sidebar toggle

A grounded polish pass across the TUI (17 items):
- color: spread the gray tiers (AA-safe), lime working-ripple (no semantic
  green/amber), calm steady ask-label, dim completed plan-step bodies,
  bold-muted sidebar headers + stateful PLAN count
- status line: run-state chip (permission mode + effort) replaces the
  duplicated provider; composer rule is model-only; context-fill gauge down to
  the narrow tier + a sidebar context-% chip
- footer: persistent idle key-hint (managed mode) + a jump-to-bottom cue
- motion: pulsing streaming caret; running specialist spins; swarm/specialist
  agents linger with a fading ✓ before removal (no abrupt pop)
- layout: Ctrl+B toggles the sidebar; padded ' │ ' divider; sidebar gated to
  the medium tier so it never starves the chat
- home screen: cwd · branch · model orientation + example prompts
- cleanup: drop dead autoTag, fix 'six swarm tools' miscount

go build/vet/test green; -race clean; no hex outside theme.go.

* feat(tui): reading column for the chat transcript

Cap transcript body rows at a readable ~90-col measure and indent them by a
small left gutter, so on a wide terminal the chat text no longer runs
edge-to-edge. Only true transcript rows use the reading column; the frame,
composer, status line, sidebar, title bar, empty state, and prompts keep the
full chatColumnWidth. Streaming text shares the column so it doesn't snap on
finalize. Plus extra vertical spacing before headings and between paragraphs.

Implementation keeps the height cache valid (rows render at contentWidth, which
the width-keyed cache already captures) and the gutter is a horizontal post-pad;
each selectable line's textStart is shifted by the gutter so click-to-select and
the selection highlight stay aligned. Mouse card/toggle hit-testing is Y-based,
so it's unaffected. go build/vet/test green; -race clean.

* fix(tui): plain cancellation marker + copyable card/toggle rows

- 'Run cancelled.' renders as a plain amber '⊘ Run cancelled.' line instead of a
  heavy box (other system notices keep their box).
- Copy root cause: the app's text-selection skipped 'button' rows (collapsible
  toggles, specialist cards) — they carried no selectable text, so a drag-select
  omitted exactly that (dull) content. Fix: those rows now carry their plain text
  + start, so a selection dragged THROUGH them copies their content; a direct
  click still toggles/drills (resolved on press, before selection). The selection
  highlight no longer excludes them. Permission buttons stay excluded.
- Surface Ctrl+E in the idle hint — it releases the mouse for native terminal
  selection, which copies everything (the zero-risk full-copy path).

go build/vet/test green; -race clean.

* fix(tui): render all system notices as plain lines, not boxes

Every system notice (mouse-mode changes, mode set, run cancelled, etc.) now
renders as a plain marked line instead of a bordered box: a faint '·' marker +
muted text for info, amber '⊘' for a run cancellation. Multi-line notices keep
the marker on the first line and indent the rest. Command cards and error rows
keep their boxes. go build/vet/test green.

* fix(tui): separate think→act tool groups with a blank line

Reasoning headers (Thought for X) interleave the tool cards (tool → thought →
tool …), which broke the existing tool-card→tool-card gap rule, so consecutive
list_directory/read_file/update_plan groups packed into a dense wall. Now a
reasoning header that follows a tool card gets a blank line above it, so each
think→act group reads as a distinct block. go build/vet/test green.

* feat(tui): colour markdown emphasis + headings for readability

Assistant prose now reads as three tiers instead of one flat ink colour:
- body text stays ink
- bold / important words render in the brand accent (lime) so they pop
- markdown headings render accent + bold + underline as a clear top tier

go build/vet/test green.

* fix(tui): calmer prose colours + a working-phase label

- Revert the accent colour on bold/important words: emphasis is weight-only now
  (no colour), dark-mode-friendly.
- Headings drop the bright lime — distinguished by ink + bold + underline instead
  of a saturated accent colour.
- Working status line gains a phase label ('writing' while the answer streams,
  'thinking' otherwise) so a long, output-less step reads as live progress
  ('Working · thinking · 55s') rather than a frozen screen.

go build/vet/test green.

* fix(tui): paint selection highlight in the gutter-shifted coordinate

Mouse text-selection looked like it started in the wrong place: the highlight
landed a few cells right of where you clicked, so selecting/copying felt
impossible to aim.

Cause: the row renderers painted the selection highlight on the UNshifted,
unpadded line, then the body item applied the reading-column gutter (pad lines +
shift selectable textStart) afterward. But the stored selection points are in the
shifted/displayed coordinate (that's how the mouse maps them), so the highlight
was computed against unshifted textStart and landed gutter-cells off. (The copied
text was actually correct — extraction uses the shifted selectable — but the
visual selection was wrong, which is what made it un-aimable.)

Fix: stop highlighting inside the row renderers; add finalizeTranscriptBodyRow,
which pads + shifts THEN paints the highlight, so it's computed in the same
coordinate the mouse uses and lands exactly on the click. Also applies the
highlight uniformly to all rows + the interim block. Regression test added.
2026-06-23 10:51:47 +05:30

186 lines
6.8 KiB
Go

package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
func TestTranscriptFrameLayoutPinsMainRegions(t *testing.T) {
m := mouseTestModel()
m.width = 100
m.height = 24
m.providerName = "openai"
m.modelName = "gpt-4.1"
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.headerRect != (tuiRect{width: width, height: len(frame.headerLines)}) {
t.Fatalf("header rect = %#v, want y=0 width=%d height=%d", frame.headerRect, width, len(frame.headerLines))
}
if frame.bodyRect.y != frame.headerRect.height {
t.Fatalf("body y = %d, want below header height %d", frame.bodyRect.y, frame.headerRect.height)
}
if frame.footerRect.y != frame.bodyRect.y+frame.bodyRect.height {
t.Fatalf("footer y = %d, want after body at %d", frame.footerRect.y, frame.bodyRect.y+frame.bodyRect.height)
}
if frame.footerRect.y+frame.footerRect.height != m.height {
t.Fatalf("footer bottom = %d, want terminal height %d", frame.footerRect.y+frame.footerRect.height, m.height)
}
if frame.composerRect.height <= 0 ||
!frame.footerRect.contains(frame.composerRect.x, frame.composerRect.y) ||
!frame.footerRect.contains(frame.composerRect.x+frame.composerRect.width-1, frame.composerRect.y+frame.composerRect.height-1) {
t.Fatalf("composer rect should be visible inside footer, frame=%#v", frame)
}
if frame.statusRect.height != 1 || frame.statusRect.y != frame.footerRect.y+frame.footerRect.height-1 {
t.Fatalf("status rect = %#v, want final visible footer line", frame.statusRect)
}
}
func TestTranscriptFrameLayoutClipsFooterInTinyTerminal(t *testing.T) {
m := mouseTestModel()
m.width = 44
m.height = 3
m.copyStatus = "Copied!"
m.input.SetValue("Create a book library dashboard page with cards, filters, charts, and responsive behavior.")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.bodyRect.height != 1 {
t.Fatalf("body height = %d, want one protected viewport line", frame.bodyRect.height)
}
if frame.footerRect.height != m.height-1 {
t.Fatalf("footer height = %d, want clipped to %d", frame.footerRect.height, m.height-1)
}
if frame.headerRect.height != 0 {
t.Fatalf("header should clip out before body disappears, got %#v", frame.headerRect)
}
if frame.footerClip <= 0 {
t.Fatalf("footerClip = %d, want clipped full footer", frame.footerClip)
}
if len(frame.footerLines) >= len(frame.fullFooterLines) {
t.Fatalf("footer lines were not clipped: visible=%d full=%d", len(frame.footerLines), len(frame.fullFooterLines))
}
}
func TestTranscriptFrameLayoutHandlesDegenerateDimensions(t *testing.T) {
for _, size := range []struct {
name string
width int
height int
}{
{name: "zero", width: 0, height: 0},
{name: "negative", width: -10, height: -4},
} {
t.Run(size.name, func(t *testing.T) {
m := mouseTestModel()
m.width = size.width
m.height = size.height
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if width <= 0 {
t.Fatalf("chatWidth(%d) = %d, want positive fallback", size.width, width)
}
if frame.bodyRect.height < 1 {
t.Fatalf("body rect = %#v, want protected body height", frame.bodyRect)
}
if frame.width != width {
t.Fatalf("frame width = %d, want chat width %d", frame.width, width)
}
})
}
}
func TestFrameComposerRegionDrivesMouseHit(t *testing.T) {
m := mouseTestModel()
m.width = 44
m.height = 20
m.input.SetValue("Create a book library dashboard page with cards, filters, charts, and responsive behavior.")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
if !m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
t.Fatalf("mouse over composer y=%d should hit composer rect %#v", frame.composerRect.y, frame.composerRect)
}
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.statusRect.y)) {
t.Fatalf("mouse over status y=%d should not hit composer rect %#v", frame.statusRect.y, frame.composerRect)
}
}
func TestOverlayMouseRectCentersInsideTranscriptBody(t *testing.T) {
m := mouseTestModel()
m.width = 100
m.height = 30
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
rect := m.overlayMouseRect(5, width)
wantY := frame.bodyRect.y + (frame.bodyRect.height-5)/2
if rect.y != wantY || rect.height != 5 || rect.width != width {
t.Fatalf("overlay rect = %#v, want y=%d height=5 width=%d", rect, wantY, width)
}
if !frame.bodyRect.contains(rect.x, rect.y) || !frame.bodyRect.contains(rect.x, rect.y+rect.height-1) {
t.Fatalf("overlay rect %#v should be inside body rect %#v", rect, frame.bodyRect)
}
}
func TestOverlayMouseHitClampsToVisibleBody(t *testing.T) {
m := mouseTestModel()
m.width = 80
m.height = 8
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
overlay := strings.Join([]string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"}, "\n")
rect := m.overlayMouseRect(len(viewLines(overlay)), width)
if rect.height != frame.bodyRect.height {
t.Fatalf("overlay rect = %#v, want height clamped to body rect %#v", rect, frame.bodyRect)
}
if _, ok := m.overlayMouseHit(testMouseClick(tea.MouseLeft, 1, rect.y+rect.height-1), overlay, width); !ok {
t.Fatalf("click inside visible overlay rect %#v should hit", rect)
}
if _, ok := m.overlayMouseHit(testMouseClick(tea.MouseLeft, 1, rect.y+rect.height), overlay, width); ok {
t.Fatalf("click below visible overlay rect %#v should not hit invisible overlay rows", rect)
}
}
func TestTranscriptLineAtMouseUsesFrameBodyRegion(t *testing.T) {
m := mouseTestModel()
m.width = 90
m.height = 12
m.transcript = appendRow(m.transcript, rowUser, "target text")
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
start, _, _ := transcriptViewportStartForFrame(body, frame, m.chatScrollOffset)
var target transcriptSelectableLine
for _, line := range selectable {
if strings.Contains(line.text, "target text") {
target = line
break
}
}
if target.text == "" {
t.Fatalf("target line missing from selectable lines: %#v", selectable)
}
hit, ok := m.transcriptLineAtMouse(testMouseClick(tea.MouseLeft, target.textStart, frame.bodyRect.y+target.bodyY-start))
if !ok || hit.text != target.text {
t.Fatalf("transcript line hit = %#v ok=%v, want %#v", hit, ok, target)
}
if _, ok := m.transcriptLineAtMouse(testMouseClick(tea.MouseLeft, target.textStart, frame.headerRect.y)); ok {
t.Fatal("header click should not map to transcript body")
}
}