Files
zero/internal/update/update.go
6fb0231df8 TUI scrollback re-architecture, Lime chat surface, and deep-audit fixes (#158)
* docs(design): add Lime TUI prototype and rebuild spec

* tui: remove the zeroline skin system

One design remains in the tree ahead of the Lime rebuild: the zeroline
package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark,
model skin branches, theme picker), and the zeroline CLI subcommand are
gone. /theme keeps its shell-only message.

* tui: replace the cyan palette with the Lime token table

theme.go is rebuilt around the Lime design tokens (docs/design/
zero_tui_lime.html): near-black panels, one lime accent, solid tint
stand-ins for the prototype's rgba overlays. Existing render sites map
onto the new role styles (ink/line/userPrompt/toolName/toolArg) without
layout changes; no hex exists outside theme.go.

* tui: build the Lime chat-surface components

Title bar with brand badge + context window, empty state replacing the
deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream
blocks (user prompt gutter, muted interim with cursor, final-answer
accent rail, done line, bordered notes), tool cards with diff/read/
bash/grep bodies and MiniDot run spinner, borderless composer with
run-state hints, and the grouped status line. Final answers are marked
at append time; resolved tool calls collapse into their result cards.

* tui: fix Stage 2 review findings

Adversarial review of the chat-surface commit confirmed 15 issues, all
fixed here:
- wrapPlainText sliced wide runes by rune count: CJK/emoji prose could
  panic the TUI or emit lines ~2x the measure; split at width instead
- a cancelled run's streamingText leaked into the next turn's interim
  block; cancelRun now clears it
- orphaned tool-call cards (cancelled/errored turns) re-animated with
  the spinner on any later run; the glyph is now scoped to the call's
  own runID
- starter-suggestion digit capture was active while the chips were off
  screen (/clear mid-run); the guard now mirrors the render condition
- prompt submits while a deferred Ctrl+C quit is draining could start a
  run the quit would orphan; submission is blocked while exiting
- the running placeholder advertised ctrl+c (which quits) as the
  interrupt key; it now says esc
- read-card parser expected 'N: text' but read_file emits 'N | text',
  so the gutter and L-range never rendered on real output
- toolCard borders were one column short of the body rows
- diff tint bands now span the full row; NEW FILE renders green on
  addBg; preamble/no-newline lines are unnumbered meta and no longer
  shift hunk numbering
- card bodies and system notes paint the panel surface; the head gains
  the separate faintest arg column (grep target + pattern)
- title-bar overflow now truncates the minimal candidate instead of
  rendering the deleted design's 'ZERO … READY' line
- untracked the stray snake-game.html (left on disk, ignored)

* tui: restyle the interactive surfaces for Lime

Permission prompts render as the amber permission card (PERMISSION
badge, risk readout, key chips); resolved prompts collapse to one line
(allowed once/always/denied · tool) and unprompted allows fold into the
tool card's [auto] tag. Ask-user and spec-review prompts share the card
language with line borders. Autocomplete and picker overlays move onto
the panel/selection tints with the accent ❯ marker; model-picker rows
gain the provider dot and ctx · KEY_ENV metadata from the registry and
provider catalog. /resume renders its list as stacked session cards
(id + age, title, meta). Key handling is untouched everywhere.

* tui: adaptive width tiers, doc updates, final polish

Width tiers re-evaluate from the live width: ≥100 renders the full
spec; 80–99 drops the tool-arg column, header ctx, and the status
'interactive' group; 58–79 drops the diff/read gutters, bares the
badge, and keeps provider+tokens+mode; <58 renders a single-segment
header, rail-less cards, and a mode-only status line. Table-driven
tests cover {58,70,80,100,120} plus a frame-wide invariant that no
emitted line exceeds the terminal at any tier. Card bodies expand tabs
so width math holds. Dead splash-chip machinery (startupCommandNames,
startupOrder) is gone; README's TUI section now describes Lime.

* tui: fix final-review findings across Stages 3-4

A second adversarial review confirmed 14 issues, all addressed:
- permission collapse and tool-card pairing are now run-scoped (rcKey):
  providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N)
  could attribute a decision or result to a different run's call
- rehydrated always-grants no longer mislabel as 'allowed once'
  (GrantMatched fallback)
- the width invariant now actually holds everywhere: empty-state
  tagline/hint, ask-user rows, permission detail blobs, pending image
  chips, and the sessions trailing hint are wrapped or fitted; the
  invariant test renders the empty state and all of those rows at
  widths 24-120
- every card (permission, ask-user, spec-review, sessions, notes)
  drops side borders on tiny terminals, not just tool cards
- /resume card fields are sanitized against separator bytes from
  user-controlled titles
- the picker's selected row paints its gap so the selBg band is solid
- dead splash helpers (normalizedStartupWidth, countLines, nonEmpty,
  unreachable rowWelcome branch) and stale /theme comments are gone
- README no longer claims transcript scrolling keys or Tab-accept
  behavior that never existed; chatWidth documents its 24-col floor

Tag tui-lime-s4 moves to this commit.

* audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes

TUI: settled-row flush frontier prints history to native terminal
scrollback (tea.Println, ordered + ack-serialized); View() renders only
the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links
land in history; streamed interim text, cancellation markers, ask_user
exchanges, and composer input history are preserved; run-scoped dedup
keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and
cross-session late flushes no longer orphan or contaminate checkpoints.

Cross-module: zero search panic + offset mapping, usage report
robustness, 529 retry, SSE keep-alive watchdog, registry corrections,
update --check on dev builds, MCP ping + mcp list --json secret
redaction, sandbox.network config knob, doctor apiKey presence, atomic
config writes, C1-safe notifications, rune-safe truncation everywhere,
gofmt + vet CI gates, dead glamour dependency dropped.

Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings).

Co-Authored-By: Claude Fable 5 <[email protected]>

* tui: hide malformed ask_user tool errors

* fix tui malformed tool retries

* fix max-turn finalization

* upgrade tui agent guardrails

* fix coderabbit guardrail findings

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
2026-06-10 17:18:30 +05:30

453 lines
13 KiB
Go

package update
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
const (
DefaultRepository = "Gitlawb/zero"
DefaultTimeout = 5 * time.Second
)
type Release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url,omitempty"`
}
type Result struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
ReleaseURL string `json:"releaseUrl"`
TagName string `json:"tagName"`
ReleaseAsset AssetCheck `json:"releaseAsset"`
UpdateAvailable bool `json:"updateAvailable"`
}
type AssetCheck struct {
Platform string `json:"platform"`
Arch string `json:"arch"`
ArchiveName string `json:"archiveName"`
ArchiveURL string `json:"archiveUrl,omitempty"`
ChecksumName string `json:"checksumName"`
ChecksumURL string `json:"checksumUrl,omitempty"`
ArchiveFound bool `json:"archiveFound"`
ChecksumFound bool `json:"checksumFound"`
Verified bool `json:"verified"`
}
// Target identifies a supported release archive target.
type Target struct {
Name string `json:"name"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Platform string `json:"platform"`
Arch string `json:"arch"`
}
// Options configures a release update check.
type Options struct {
CurrentVersion string
// Endpoint accepts a full release API URL, an owner/repo slug, or a data:
// endpoint for deterministic tests.
Endpoint string
Repository string
Timeout time.Duration
GOOS string
GOARCH string
// Fetch overrides the release fetcher for tests and alternate transports.
Fetch func(context.Context, string) (Release, error)
}
type semverParts [3]int
var (
versionPattern = regexp.MustCompile(`^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+].*)?$`)
repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
)
// Endpoint returns the GitHub latest-release API endpoint for a repository.
func Endpoint(repository string) string {
return fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository)
}
// ResolveEndpoint resolves a URL or owner/repo slug into a release API endpoint.
func ResolveEndpoint(endpointOrRepository string, repository string) (string, error) {
return resolveEndpoint(endpointOrRepository, repository)
}
// NormalizeVersionTag returns a comparable x.y.z version from a release tag.
func NormalizeVersionTag(version string) (string, error) {
return normalizeVersionTag(version)
}
// CompareSemver compares two semver-ish release tags.
func CompareSemver(left string, right string) (int, error) {
leftParts, err := parseSemver(left)
if err != nil {
return 0, err
}
rightParts, err := parseSemver(right)
if err != nil {
return 0, err
}
return compareSemverParts(leftParts, rightParts), nil
}
// ResolveTarget maps a release target name like windows-x64 to Go build
// coordinates and release asset naming fields.
func ResolveTarget(target string) (Target, error) {
value := strings.TrimSpace(strings.ToLower(target))
switch value {
case "linux-x64":
return Target{Name: value, GOOS: "linux", GOARCH: "amd64", Platform: "linux", Arch: "x64"}, nil
case "linux-arm64":
return Target{Name: value, GOOS: "linux", GOARCH: "arm64", Platform: "linux", Arch: "arm64"}, nil
case "macos-x64":
return Target{Name: value, GOOS: "darwin", GOARCH: "amd64", Platform: "macos", Arch: "x64"}, nil
case "macos-arm64":
return Target{Name: value, GOOS: "darwin", GOARCH: "arm64", Platform: "macos", Arch: "arm64"}, nil
case "windows-x64":
return Target{Name: value, GOOS: "windows", GOARCH: "amd64", Platform: "windows", Arch: "x64"}, nil
case "windows-arm64":
return Target{Name: value, GOOS: "windows", GOARCH: "arm64", Platform: "windows", Arch: "arm64"}, nil
default:
return Target{}, fmt.Errorf("unsupported update target %q: expected one of linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64, windows-arm64", target)
}
}
func Check(ctx context.Context, options Options) (Result, error) {
rawVersion := strings.TrimSpace(firstNonEmpty(options.CurrentVersion, "0.0.0"))
currentVersion, err := normalizeVersionTag(rawVersion)
if err != nil {
// Source/dev builds carry a non-semver version ("dev"); the check is
// still useful there — compare as 0.0.0 so the latest release is always
// reported as available instead of failing before the network call.
currentVersion = "0.0.0"
}
repository := strings.TrimSpace(firstNonEmpty(options.Repository, DefaultRepository))
endpoint, err := resolveEndpoint(firstNonEmpty(options.Endpoint, os.Getenv("ZERO_UPDATE_RELEASE_URL")), repository)
if err != nil {
return Result{}, err
}
timeout := options.Timeout
if timeout < 0 {
return Result{}, fmt.Errorf("timeout must be non-negative")
}
if timeout == 0 {
timeout = DefaultTimeout
}
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
fetch := options.Fetch
if fetch == nil {
fetch = fetchRelease
}
release, err := fetch(ctx, endpoint)
if err != nil {
return Result{}, err
}
if strings.TrimSpace(release.TagName) == "" {
return Result{}, fmt.Errorf("github release response did not include a tag_name")
}
latestVersion, err := normalizeVersionTag(release.TagName)
if err != nil {
return Result{}, err
}
releaseURL := strings.TrimSpace(release.HTMLURL)
if releaseURL == "" {
releaseURL = fmt.Sprintf("https://github.com/%s/releases/tag/%s", repository, release.TagName)
}
assetCheck, err := verifyReleaseAssets(release, latestVersion, options)
if err != nil {
return Result{}, err
}
latestParts, err := parseSemverNormalized(latestVersion)
if err != nil {
return Result{}, err
}
currentParts, err := parseSemverNormalized(currentVersion)
if err != nil {
return Result{}, err
}
return Result{
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseURL: releaseURL,
TagName: release.TagName,
ReleaseAsset: assetCheck,
UpdateAvailable: compareSemverParts(latestParts, currentParts) > 0,
}, nil
}
func Format(result Result) string {
if result.UpdateAvailable {
lines := []string{
fmt.Sprintf("[zero] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion),
"Release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
if target := releaseAssetTarget(result.ReleaseAsset); target != "" {
lines = append(lines, "Download the verified "+target+" release asset, then replace the current zero binary.")
} else {
lines = append(lines, "Download the verified release asset, then replace the current zero binary.")
}
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("[zero] up to date (%s)", result.CurrentVersion),
"Latest release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
return strings.Join(lines, "\n")
}
func appendAssetLines(lines []string, asset AssetCheck) []string {
if asset.ArchiveName == "" {
return lines
}
if target := releaseAssetTarget(asset); target != "" {
lines = append(lines, "Release target: "+target)
}
lines = append(lines, "Release asset: "+asset.ArchiveName)
if asset.ChecksumName != "" {
lines = append(lines, "Checksum asset: "+asset.ChecksumName)
}
return lines
}
func releaseAssetTarget(asset AssetCheck) string {
if asset.Platform == "" || asset.Arch == "" {
return ""
}
return asset.Platform + "-" + asset.Arch
}
func fetchRelease(ctx context.Context, endpoint string) (release Release, err error) {
if strings.HasPrefix(endpoint, "data:") {
return fetchDataRelease(endpoint)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Release{}, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", "zero/update")
response, err := http.DefaultClient.Do(request)
if err != nil {
return Release{}, err
}
defer func() {
if closeErr := response.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("close update response: %w", closeErr)
}
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return Release{}, fmt.Errorf("github release check failed (%s)", response.Status)
}
if err := json.NewDecoder(response.Body).Decode(&release); err != nil {
return Release{}, err
}
return release, nil
}
func fetchDataRelease(endpoint string) (Release, error) {
comma := strings.Index(endpoint, ",")
if comma == -1 {
return Release{}, fmt.Errorf("invalid data update endpoint")
}
payload, err := url.QueryUnescape(endpoint[comma+1:])
if err != nil {
return Release{}, err
}
var release Release
if err := json.Unmarshal([]byte(payload), &release); err != nil {
return Release{}, err
}
return release, nil
}
func resolveEndpoint(endpointOrRepository string, repository string) (string, error) {
value := strings.TrimSpace(endpointOrRepository)
if value == "" {
return Endpoint(repository), nil
}
if repositoryPattern.MatchString(value) {
return Endpoint(value), nil
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme == "" {
return "", fmt.Errorf("invalid update endpoint %q: use a full URL or an owner/repo slug like %s", value, repository)
}
return value, nil
}
func verifyReleaseAssets(release Release, version string, options Options) (AssetCheck, error) {
assetCheck, err := expectedAssetCheck(version, options.GOOS, options.GOARCH)
if err != nil {
return AssetCheck{}, err
}
for _, asset := range release.Assets {
name := strings.TrimSpace(asset.Name)
switch name {
case assetCheck.ArchiveName:
assetCheck.ArchiveFound = true
assetCheck.ArchiveURL = strings.TrimSpace(asset.BrowserDownloadURL)
case assetCheck.ChecksumName:
assetCheck.ChecksumFound = true
assetCheck.ChecksumURL = strings.TrimSpace(asset.BrowserDownloadURL)
}
}
assetCheck.Verified = assetCheck.ArchiveFound && assetCheck.ChecksumFound
if assetCheck.Verified {
return assetCheck, nil
}
missing := []string{}
if !assetCheck.ArchiveFound {
missing = append(missing, assetCheck.ArchiveName)
}
if !assetCheck.ChecksumFound {
missing = append(missing, assetCheck.ChecksumName)
}
return AssetCheck{}, fmt.Errorf("release metadata missing expected asset(s) for %s-%s: %s", assetCheck.Platform, assetCheck.Arch, strings.Join(missing, ", "))
}
func expectedAssetCheck(version string, goos string, goarch string) (AssetCheck, error) {
platform, err := releasePlatform(firstNonEmpty(goos, runtime.GOOS))
if err != nil {
return AssetCheck{}, err
}
arch, err := releaseArch(firstNonEmpty(goarch, runtime.GOARCH))
if err != nil {
return AssetCheck{}, err
}
extension := "tar.gz"
if platform == "windows" {
extension = "zip"
}
archiveName := fmt.Sprintf("zero-v%s-%s-%s.%s", version, platform, arch, extension)
return AssetCheck{
Platform: platform,
Arch: arch,
ArchiveName: archiveName,
ChecksumName: archiveName + ".sha256",
}, nil
}
func releasePlatform(goos string) (string, error) {
switch strings.TrimSpace(goos) {
case "linux":
return "linux", nil
case "darwin":
return "macos", nil
case "windows":
return "windows", nil
default:
return "", fmt.Errorf("unsupported release platform: %s", goos)
}
}
func releaseArch(goarch string) (string, error) {
switch strings.TrimSpace(goarch) {
case "amd64":
return "x64", nil
case "arm64":
return "arm64", nil
default:
return "", fmt.Errorf("unsupported release architecture: %s", goarch)
}
}
func normalizeVersionTag(version string) (string, error) {
match := versionPattern.FindStringSubmatch(strings.TrimSpace(version))
if match == nil {
return "", fmt.Errorf("invalid semantic version: %s", version)
}
major, err := parseVersionComponent(version, match[1])
if err != nil {
return "", err
}
minor, err := parseVersionComponent(version, match[2])
if err != nil {
return "", err
}
patch, err := parseVersionComponent(version, match[3])
if err != nil {
return "", err
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
}
func parseSemver(version string) (semverParts, error) {
normalized, err := NormalizeVersionTag(version)
if err != nil {
return semverParts{}, err
}
return parseSemverNormalized(normalized)
}
func parseSemverNormalized(version string) (semverParts, error) {
parts := strings.Split(version, ".")
if len(parts) != 3 {
return semverParts{}, fmt.Errorf("invalid semantic version: %s", version)
}
major, err := parseVersionComponent(version, parts[0])
if err != nil {
return semverParts{}, err
}
minor, err := parseVersionComponent(version, parts[1])
if err != nil {
return semverParts{}, err
}
patch, err := parseVersionComponent(version, parts[2])
if err != nil {
return semverParts{}, err
}
return semverParts{major, minor, patch}, nil
}
func compareSemverParts(left semverParts, right semverParts) int {
for index := range left {
if left[index] != right[index] {
return left[index] - right[index]
}
}
return 0
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func parseVersionComponent(version string, component string) (int, error) {
parsed, err := strconv.ParseInt(component, 10, 31)
if err != nil {
return 0, fmt.Errorf("invalid semantic version: %s", version)
}
return int(parsed), nil
}