mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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.
108 lines
4.2 KiB
Go
108 lines
4.2 KiB
Go
// ripple.go adds a slow cosine "breathing" colour wave for the working status
|
|
// line: each character cycles through a dim→lime ramp blended from the brand
|
|
// accent, and the wave travels across the text one character per phase tick. The
|
|
// phase is sourced from the shared spinner clock (m.spinnerPhase, advanced by the
|
|
// existing spinner.TickMsg handler) so the ripple and the braille spinner animate
|
|
// in lock-step with no extra ticker.
|
|
package tui
|
|
|
|
import (
|
|
"image/color"
|
|
"math"
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
// rippleLevel returns a palette index in [0, travel] computed from a cosine wave
|
|
// of wavelength waveLen at integer offset distance. The wave is the standard
|
|
// "breathing" half-wave-rectified cosine:
|
|
//
|
|
// q = distance mod waveLen
|
|
// level = int( travel * (0.5 + 0.5*cos(2*Pi*q/waveLen)) )
|
|
//
|
|
// At distance == 0 the level is travel (brightest), at distance == waveLen/2 it
|
|
// is 0 (dimmest), and the cycle repeats every waveLen. A string rendered with
|
|
// rippleLevel(i+phase, ...) for i in [0, N) therefore shows a single cosine wave
|
|
// of colour that moves as phase advances.
|
|
//
|
|
// Edge cases:
|
|
// - waveLen <= 0 returns 0 (no wave; flat dimmest entry)
|
|
// - travel <= 0 returns 0 (no palette; flat dimmest entry)
|
|
// - distance < 0 mod-normalises to a non-negative value
|
|
//
|
|
// The function is intentionally pure: three ints in, an int out, depending only
|
|
// on the Go standard library math package — no model state, no rendering.
|
|
func rippleLevel(distance, travel, waveLen int) int {
|
|
if travel <= 0 || waveLen <= 0 {
|
|
return 0
|
|
}
|
|
// Normalise distance to [0, waveLen) so negative phases still behave: Go's %
|
|
// can return a negative for negative inputs.
|
|
q := distance % waveLen
|
|
if q < 0 {
|
|
q += waveLen
|
|
}
|
|
ratio := 0.5 + 0.5*math.Cos(2*math.Pi*float64(q)/float64(waveLen))
|
|
return int(float64(travel) * ratio)
|
|
}
|
|
|
|
// rippleText applies one palette style per rune of text, using rippleLevel with
|
|
// index = i+phase so the wave travels across the string one character per phase
|
|
// tick. The palette holds curated theme styles (cold-to-warm); travel = len-1 so
|
|
// the cosine spans every entry exactly once.
|
|
//
|
|
// phase advances the wave; the caller passes m.spinnerPhase so the ripple and the
|
|
// braille spinner share a single animation clock.
|
|
//
|
|
// On an empty palette or empty text the input string is returned verbatim (no
|
|
// styling) — a safe-skip contract for callers.
|
|
func rippleText(text string, palette []lipgloss.Style, phase, waveLen int) string {
|
|
if len(palette) == 0 || text == "" {
|
|
return text
|
|
}
|
|
travel := len(palette) - 1
|
|
if travel == 0 {
|
|
return palette[0].Render(text)
|
|
}
|
|
|
|
var b strings.Builder
|
|
for i, r := range text {
|
|
level := rippleLevel(i+phase, travel, waveLen)
|
|
if level < 0 {
|
|
level = 0
|
|
}
|
|
if level > travel {
|
|
level = travel
|
|
}
|
|
b.WriteString(palette[level].Render(string(r)))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ripplePalette is the run-state colour ramp used by the working status line: the
|
|
// curated zeroTheme styles ordered cold (faint) at the cosine troughs to
|
|
// warm-bright (accent) at its peaks, giving a visible "the agent is thinking"
|
|
// temperature feel. Built lazily from the active theme so a /theme swap is
|
|
// reflected. No hex literal appears here — every entry is a named theme style
|
|
// (theme.go), satisfying the repo's no-hex-outside-theme rule.
|
|
func ripplePalette() []lipgloss.Style {
|
|
// A dim→lime ramp blended from the brand accent — deliberately NOT the semantic
|
|
// amber (permission) / green (success) hues. Flashing those every spinner tick
|
|
// trains the eye to ignore the exact colours that signal a real permission
|
|
// prompt or a success, so the decorative working line stays in the brand lime.
|
|
// Falls back to a coarse named ramp if the theme has no parseable accent/faint.
|
|
bright := zeroTheme.accent.GetForeground()
|
|
dim := zeroTheme.faint.GetForeground()
|
|
if bright == nil || dim == nil {
|
|
return []lipgloss.Style{zeroTheme.faint, zeroTheme.muted, zeroTheme.accent}
|
|
}
|
|
blend := lipgloss.Blend1D(5, dim, bright)
|
|
out := make([]lipgloss.Style, len(blend))
|
|
for i, c := range blend {
|
|
r, g, b, a := c.RGBA()
|
|
out[i] = lipgloss.NewStyle().Foreground(color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)})
|
|
}
|
|
return out
|
|
}
|