Files
zero/internal/cli/usage.go
T
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

319 lines
10 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zerogit"
)
type usageOptions struct {
json bool
days int
since string
sessionID string
}
// usageEventSet keeps the persisted events paired with the session metadata they
// were read alongside, so cost reconstruction can resolve each event's owning
// model id during aggregation.
type usageEventSet struct {
events []sessions.Event
meta []sessions.Metadata
skipped int
}
// collectUsageData reads every persisted session's events (optionally limited to
// a single session id) and returns them flattened alongside the matching session
// metadata. It mirrors runSearch's persisted-event traversal over the injected
// session store. Sessions whose event log cannot be read are skipped (and
// counted) so one corrupt session can't abort the whole report.
func collectUsageData(store *sessions.Store, sessionFilter string) (usageEventSet, error) {
metadata, err := store.List()
if err != nil {
return usageEventSet{}, err
}
set := usageEventSet{}
for _, meta := range metadata {
if sessionFilter != "" && meta.SessionID != sessionFilter {
continue
}
sessionEvents, err := store.ReadEvents(meta.SessionID)
if err != nil {
set.skipped++
continue
}
set.meta = append(set.meta, meta)
set.events = append(set.events, sessionEvents...)
}
return set, nil
}
// filterEventsSince drops events whose UTC calendar date precedes the inclusive
// lower bound. An empty since returns the events unchanged.
func filterEventsSince(events []sessions.Event, since string) []sessions.Event {
if since == "" {
return events
}
filtered := make([]sessions.Event, 0, len(events))
for _, event := range events {
if eventUTCDate(event.CreatedAt) >= since {
filtered = append(filtered, event)
}
}
return filtered
}
// eventUTCDate maps an RFC3339 timestamp to its UTC calendar date (YYYY-MM-DD)
// so the --since/--days cutoff compares against the same UTC day the report
// buckets under. On a parse failure it falls back to the leading-10 slice.
func eventUTCDate(createdAt string) string {
if parsed, err := time.Parse(time.RFC3339, createdAt); err == nil {
return parsed.UTC().Format("2006-01-02")
}
if len(createdAt) >= 10 {
return createdAt[:10]
}
return createdAt
}
func runUsage(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
subcommand := "report"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
subcommand = strings.ToLower(strings.TrimSpace(args[0]))
args = args[1:]
}
if subcommand == "help" {
if err := writeUsageHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if subcommand != "report" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown usage command %q", subcommand))
}
options, help, err := parseUsageArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeUsageHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
set, err := collectUsageData(deps.newSessionStore(), options.sessionID)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
since := usageSinceCutoff(options, deps)
events := filterEventsSince(set.events, since)
// The net-LOC column is best-effort garnish on a token report: outside a
// git repository (or on any git failure) it degrades to zero instead of
// aborting the entire report.
diff := zerogit.DiffStat{}
if workspaceRoot, err := resolveWorkspaceRoot("", deps); err == nil {
if summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{Cwd: workspaceRoot}); err == nil {
// The --stat summary line ("N files changed, A insertions(+), B
// deletions(-)") carries no secret-bearing tokens, so parsing the
// already-redacted DiffStat returned by zerogit.Inspect is safe.
diff = zerogit.ParseDiffStat(summary.DiffStat)
}
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
report, err := usage.BuildReport(events, set.meta, &registry, diff.NetLOC())
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, FormatReport(report, diff.Insertions, diff.Deletions)); err != nil {
return exitCrash
}
return exitSuccess
}
// usageSinceCutoff resolves the inclusive lower-bound date (YYYY-MM-DD). An
// explicit --since wins; otherwise --days N derives a cutoff relative to the
// injected clock. An empty result means "no lower bound".
func usageSinceCutoff(options usageOptions, deps appDeps) string {
if strings.TrimSpace(options.since) != "" {
return options.since
}
if options.days > 0 {
cutoff := deps.now().UTC().AddDate(0, 0, -(options.days - 1))
return cutoff.Format("2006-01-02")
}
return ""
}
func parseUsageArgs(args []string) (usageOptions, bool, error) {
options := usageOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case arg == "--days":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
days, err := parsePositiveOrZeroInt(value, "--days")
if err != nil {
return options, false, err
}
options.days = days
index = next
case strings.HasPrefix(arg, "--days="):
days, err := parsePositiveOrZeroInt(strings.TrimSpace(strings.TrimPrefix(arg, "--days=")), "--days")
if err != nil {
return options, false, err
}
options.days = days
case arg == "--since":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
if _, parseErr := time.Parse("2006-01-02", value); parseErr != nil {
return options, false, execUsageError{fmt.Sprintf("invalid --since %q: expected YYYY-MM-DD", value)}
}
options.since = value
index = next
case strings.HasPrefix(arg, "--since="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--since="))
if _, parseErr := time.Parse("2006-01-02", value); parseErr != nil {
return options, false, execUsageError{fmt.Sprintf("invalid --since %q: expected YYYY-MM-DD", value)}
}
options.since = value
case arg == "--session" || arg == "--session-id":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
if strings.TrimSpace(value) == "" {
return options, false, execUsageError{fmt.Sprintf("%s requires a value", arg)}
}
options.sessionID = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--session="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--session="))
if value == "" {
return options, false, execUsageError{"--session requires a value"}
}
options.sessionID = value
case strings.HasPrefix(arg, "--session-id="):
value := strings.TrimSpace(strings.TrimPrefix(arg, "--session-id="))
if value == "" {
return options, false, execUsageError{"--session-id requires a value"}
}
options.sessionID = value
default:
return options, false, execUsageError{fmt.Sprintf("unknown usage flag %q", arg)}
}
}
return options, false, nil
}
// FormatReport renders a usage Report as a per-day table plus totals and net-LOC
// efficiency ratios. Cost and net-LOC are labeled as estimates: persisted usage
// events carry no model id or cost (cost is reconstructed from session
// metadata), and net-LOC is a working-tree diff proxy. The insertions/deletions
// counts come straight from the parsed working-tree --stat.
func FormatReport(report usage.Report, insertions int, deletions int) string {
var builder strings.Builder
builder.WriteString("Usage report (cost is a reconstructed estimate)\n\n")
builder.WriteString(fmt.Sprintf("%-12s %10s %14s %14s\n", "date", "requests", "tokens", "est. cost"))
for _, bucket := range report.Buckets {
builder.WriteString(fmt.Sprintf("%-12s %10d %14s %14s\n",
bucket.Date, bucket.Requests, groupThousands(bucket.TotalTokens), formatUSD(bucket.TotalCost)))
}
builder.WriteString(fmt.Sprintf("\n%-12s %10d %14s %14s\n",
"total", report.Total.Requests, groupThousands(report.Total.TotalTokens), formatUSD(report.Total.TotalCost)))
builder.WriteString(fmt.Sprintf("\nnet LOC (estimate): +%d / -%d = %d\n",
insertions, deletions, report.NetLOC))
if report.NetLOCPositive {
builder.WriteString(fmt.Sprintf("tokens per net LOC: %.1f\n", report.TokensPerNetLOC))
builder.WriteString(fmt.Sprintf("est. cost per net LOC: %s\n", formatUSD(report.CostPerNetLOC)))
} else {
builder.WriteString("tokens per net LOC: n/a (net LOC <= 0)\n")
builder.WriteString("est. cost per net LOC: n/a (net LOC <= 0)\n")
}
return strings.TrimRight(builder.String(), "\n")
}
func formatUSD(value float64) string {
formatted, err := modelregistry.FormatCostUSD(value)
if err != nil {
return "$0.0000"
}
return formatted
}
// groupThousands renders an integer with comma thousands separators, preserving
// a leading minus sign.
func groupThousands(value int) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
digits := fmt.Sprintf("%d", value)
if len(digits) <= 3 {
return sign + digits
}
var out []byte
prefix := len(digits) % 3
if prefix == 0 {
prefix = 3
}
out = append(out, digits[:prefix]...)
for index := prefix; index < len(digits); index += 3 {
out = append(out, ',')
out = append(out, digits[index:index+3]...)
}
return sign + string(out)
}
func writeUsageHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero usage report [flags]
Summarizes token usage and reconstructed (estimated) cost from persisted local
Zero session usage events, plus a working-tree net-LOC efficiency estimate.
Flags:
--json Print JSON report
--days <number> Only include the most recent N days
--since <YYYY-MM-DD> Only include events on or after this date
--session <id> Limit the report to one session
-h, --help Show this help
Cost is reconstructed from session model metadata and is an estimate; net LOC
is a working-tree diff proxy and is labeled as an estimate.
`)
return err
}