mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
* 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]>
319 lines
10 KiB
Go
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, ®istry, 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
|
|
}
|