Files
zero/internal/tui/startup.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

394 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"strings"
"unicode/utf8"
"charm.land/lipgloss/v2"
)
const (
defaultStartupWidth = 96
defaultStartupHeight = 30
minStartupWidth = 58
)
// zeroWordmarkPrefixLines is the white `ZER` part of the empty-state ANSI
// Shadow-style wordmark. zeroWordmarkOLines keeps the old lime `O` glyph.
var zeroWordmarkPrefixLines = []string{
`███████╗███████╗██████╗ `,
`╚══███╔╝██╔════╝██╔══██╗`,
` ███╔╝ █████╗ ██████╔╝`,
` ███╔╝ ██╔══╝ ██╔══██╗`,
`███████╗███████╗██║ ██║`,
`╚══════╝╚══════╝╚═╝ ╚═╝`,
}
var zeroWordmarkOLines = []string{
` ██████╗ `,
`██╔═══██╗`,
`██║ ██║`,
`██║ ██║`,
`╚██████╔╝`,
` ╚═════╝ `,
}
const emptyStateTagline = "Any model. Every tool. Zero limits."
// emptyState renders the centered stream-area block shown while the
// transcript has no real content: the brand glyph and tagline.
func (m model) emptyState(width int) string {
lines := m.emptyStateLines(width)
// Vertically center within the stream area: the frame around it (title bar,
// rules, composer, status line) occupies ~6 terminal rows.
height := normalizedStartupHeight(m.height)
gap := clamp((height-6-len(lines))/2, 0, 12)
return strings.Repeat("\n", gap) + strings.Join(lines, "\n") + strings.Repeat("\n", gap)
}
func (m model) emptyStateWithOverlay(width int, overlay string) string {
lines := viewLines(overlay)
for index := range lines {
lines[index] = fitStyledLine(lines[index], width)
}
// Center the palette in the visible chat area. While the command palette is
// open it replaces the empty-state wordmark instead of sitting below it.
available := normalizedStartupHeight(m.height) - 5
if m.titleBarInTranscriptBody() {
available -= 2
}
gap := maxInt(0, (available-len(lines))/2)
return strings.Repeat("\n", gap) + strings.Join(lines, "\n") + strings.Repeat("\n", gap)
}
func (m model) emptyStateLines(width int) []string {
lines := []string{}
for _, glyph := range zeroWordmarkLines() {
lines = append(lines, centerLine(glyph, width))
}
lines = append(lines, "")
lines = append(lines, centerLine(zeroTheme.muted.Render(emptyStateTagline), width))
// Orientation: where ZERO is pointed (cwd · branch · model) so a returning user
// sees the context before typing instead of a blank brand screen.
if orient := m.emptyStateOrientation(); orient != "" {
lines = append(lines, "")
lines = append(lines, centerLine(orient, width))
}
// A couple of example prompts to seed the first message.
lines = append(lines, "")
lines = append(lines, centerLine(zeroTheme.faint.Render(emptyStateExamples), width))
lines = append(lines, "")
lines = append(lines, centerLine(zeroTheme.faint.Render("Press ? for keyboard shortcuts · / for commands"), width))
// centerLine pads but never truncates; below ~62 cols the lines would exceed
// the frame without this fit.
for index := range lines {
lines[index] = fitStyledLine(lines[index], width)
}
return lines
}
// emptyStateExamples seeds the first prompt with a few representative asks.
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
// screen, omitting any piece that's unknown. Empty when nothing is known.
func (m model) emptyStateOrientation() string {
parts := make([]string, 0, 3)
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
parts = append(parts, shortenPath(cwd))
}
if branch := strings.TrimSpace(m.gitBranch); branch != "" {
parts = append(parts, branch)
}
if model := strings.TrimSpace(m.modelName); model != "" {
parts = append(parts, model)
}
if len(parts) == 0 {
return ""
}
return zeroTheme.faint.Render(strings.Join(parts, " · "))
}
func zeroWordmarkLines() []string {
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
lines = append(lines, zeroTheme.ink.Render(zeroWordmarkPrefixLines[index])+zeroTheme.accent.Render(zeroWordmarkOLines[index]))
}
return lines
}
func borderedBlock(width int, lines []string) string {
return styledBlock(width, lines, zeroTheme.line)
}
// styledBlock draws a rounded box around lines with the given border style,
// padding every row to the full width.
func styledBlock(width int, lines []string, borderStyle lipgloss.Style) string {
return styledBlockFill(width, lines, borderStyle, lipgloss.NewStyle())
}
// styledBlockFill is styledBlock with a fill style painting the row padding,
// so tinted cards (permission, panel surfaces) read as solid bands. On tiny
// terminals every card loses its side borders (top/bottom rules stay) so the
// 4 border cells go back to content.
func styledBlockFill(width int, lines []string, borderStyle lipgloss.Style, fill lipgloss.Style) string {
if width < 4 {
width = 4
}
if widthTier(width) == tierTiny {
rule := borderStyle.Render(strings.Repeat("─", width))
body := make([]string, 0, len(lines)+2)
body = append(body, rule)
for _, line := range lines {
body = append(body, fitStyledLine(line, width))
}
body = append(body, rule)
return strings.Join(body, "\n")
}
rule := strings.Repeat("─", width-2)
top := borderStyle.Render("╭" + rule + "╮")
bottom := borderStyle.Render("╰" + rule + "╯")
body := make([]string, 0, len(lines)+2)
body = append(body, top)
for _, line := range lines {
available := width - 4
fitted := fitStyledLine(line, available)
pad := fill.Render(strings.Repeat(" ", maxInt(0, available-lipgloss.Width(fitted))))
body = append(body, borderStyle.Render("│ ")+fitted+pad+borderStyle.Render(" │"))
}
body = append(body, bottom)
return strings.Join(body, "\n")
}
// middleTruncate shortens a path-like value from the middle so both the
// leading segment and the file name survive: internal/…/root.go.
func middleTruncate(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return truncateRunes(value, limit)
}
keep := limit - 1
front := keep / 2
back := keep - front
return string(runes[:front]) + "…" + string(runes[len(runes)-back:])
}
func joinHeaderLine(left string, right string, width int) string {
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return left + " " + right
}
return left + strings.Repeat(" ", gap) + right
}
type headerCandidate struct {
left string
right string
}
func startupHeaderLine(width int, candidates []headerCandidate) string {
for _, candidate := range candidates {
line := joinHeaderLine(candidate.left, candidate.right, width)
if lipgloss.Width(line) <= width {
return line
}
}
// Nothing fits whole: truncate the most minimal candidate rather than
// inventing different content.
if len(candidates) == 0 {
return ""
}
last := candidates[len(candidates)-1]
return fitStyledLine(joinHeaderLine(last.left, last.right, width), width)
}
func centerLine(line string, width int) string {
padding := (width - lipgloss.Width(line)) / 2
if padding < 0 {
padding = 0
}
return strings.Repeat(" ", padding) + line
}
func rightAlignedLine(line string, width int) string {
padding := width - lipgloss.Width(line)
if padding < 0 {
padding = 0
}
return strings.Repeat(" ", padding) + line
}
func indentBlock(block string, spaces int) string {
if spaces <= 0 {
return block
}
prefix := strings.Repeat(" ", spaces)
lines := strings.Split(block, "\n")
for index, line := range lines {
lines[index] = prefix + line
}
return strings.Join(lines, "\n")
}
func fitStyledLine(line string, width int) string {
if width <= 0 {
return ""
}
if lipgloss.Width(line) <= width {
return line
}
return truncateStyledLine(line, width)
}
func truncateStyledLine(line string, width int) string {
const resetANSI = "\x1b[0m"
ellipsis := "…"
ellipsisWidth := lipgloss.Width(ellipsis)
if width <= ellipsisWidth {
return ellipsis
}
targetWidth := width - ellipsisWidth
usedWidth := 0
sawANSI := false
openLink := false
var builder strings.Builder
for index := 0; index < len(line); {
if line[index] == '\x1b' {
end := ansiSequenceEnd(line, index)
if end > index {
sequence := line[index:end]
builder.WriteString(sequence)
sawANSI = true
// Track OSC 8 hyperlink state: truncating between an open and
// its terminator would leak the link onto everything after.
if strings.HasPrefix(sequence, "\x1b]8;") {
openLink = sequence != "\x1b]8;;\x1b\\" && sequence != "\x1b]8;;\a"
}
index = end
continue
}
}
glyph, size := utf8.DecodeRuneInString(line[index:])
if glyph == utf8.RuneError && size == 0 {
break
}
glyphWidth := lipgloss.Width(string(glyph))
if usedWidth+glyphWidth > targetWidth {
break
}
builder.WriteString(line[index : index+size])
usedWidth += glyphWidth
index += size
}
if openLink {
builder.WriteString("\x1b]8;;\x1b\\")
}
builder.WriteString(ellipsis)
if sawANSI {
builder.WriteString(resetANSI)
}
return builder.String()
}
func ansiSequenceEnd(value string, start int) int {
if start >= len(value) || value[start] != '\x1b' {
return start
}
index := start + 1
if index >= len(value) {
return index
}
switch value[index] {
case '[':
// CSI: terminated by a final byte in 0x40–0x7e.
for index++; index < len(value); index++ {
if value[index] >= 0x40 && value[index] <= 0x7e {
return index + 1
}
}
return len(value)
case ']':
// OSC (e.g. the OSC 8 hyperlinks on tool-card paths): terminated by
// BEL or ST (ESC \). Without this branch the truncator treated the
// payload as printable text, wrecking the width math.
for index++; index < len(value); index++ {
if value[index] == '\a' {
return index + 1
}
if value[index] == '\x1b' && index+1 < len(value) && value[index+1] == '\\' {
return index + 2
}
}
return len(value)
default:
return minInt(start+2, len(value))
}
}
// chatWidth resolves the render width for the chat surface. Unlike the old
// splash floor it respects genuinely tiny terminals (so the <58 tier can
// engage). The 24-cell floor is deliberate: below it the cards' own minimum
// width makes the layout meaningless, so we accept terminal-side wrapping
// there rather than degrade every wider tier.
func chatWidth(width int) int {
if width <= 0 {
return defaultStartupWidth
}
if width < 24 {
return 24
}
return width
}
func normalizedStartupHeight(height int) int {
if height <= 0 {
return defaultStartupHeight
}
if height < 18 {
return 18
}
return height
}
func clamp(value int, minimum int, maximum int) int {
if value < minimum {
return minimum
}
if value > maximum {
return maximum
}
return value
}
func maxInt(left int, right int) int {
if left > right {
return left
}
return right
}
func minInt(left int, right int) int {
if left < right {
return left
}
return right
}