mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -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]>
468 lines
14 KiB
Go
468 lines
14 KiB
Go
package search
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
)
|
|
|
|
const (
|
|
IndexFileName = "search-index.json"
|
|
indexSchema = 1
|
|
defaultLimit = 20
|
|
defaultContext = 80
|
|
)
|
|
|
|
type Options struct {
|
|
Store *sessions.Store
|
|
RootDir string
|
|
Limit int
|
|
ContextChars int
|
|
SessionID string
|
|
Type sessions.EventType
|
|
Reindex bool
|
|
Now func() time.Time
|
|
}
|
|
|
|
type EventSummary struct {
|
|
ID string `json:"id"`
|
|
Sequence int `json:"sequence"`
|
|
Type sessions.EventType `json:"type"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type Hit struct {
|
|
Session sessions.Metadata `json:"session"`
|
|
Event EventSummary `json:"event"`
|
|
Context string `json:"context"`
|
|
Match Match `json:"match"`
|
|
}
|
|
|
|
type Match struct {
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
}
|
|
|
|
type Result struct {
|
|
Query string `json:"query"`
|
|
NormalizedQuery string `json:"normalizedQuery"`
|
|
RootDir string `json:"rootDir"`
|
|
SearchedSessions int `json:"searchedSessions"`
|
|
// SkippedSessions counts sessions whose event log could not be read or
|
|
// indexed; they are skipped so one corrupt session can't abort the search.
|
|
SkippedSessions int `json:"skippedSessions,omitempty"`
|
|
TotalHits int `json:"totalHits"`
|
|
Hits []Hit `json:"hits"`
|
|
}
|
|
|
|
type Index struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
SessionID string `json:"sessionId"`
|
|
SessionUpdatedAt string `json:"sessionUpdatedAt"`
|
|
SessionEventCount int `json:"sessionEventCount"`
|
|
GeneratedAt string `json:"generatedAt"`
|
|
Entries []IndexEntry `json:"entries"`
|
|
}
|
|
|
|
type IndexEntry struct {
|
|
SessionID string `json:"sessionId"`
|
|
EventID string `json:"eventId"`
|
|
Sequence int `json:"sequence"`
|
|
Type sessions.EventType `json:"type"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
func Sessions(query string, options Options) (Result, error) {
|
|
normalized := NormalizeQuery(query)
|
|
store := options.Store
|
|
if store == nil {
|
|
store = sessions.NewStore(sessions.StoreOptions{RootDir: options.RootDir})
|
|
}
|
|
limit := normalizeLimit(options.Limit)
|
|
contextChars := normalizeContext(options.ContextChars)
|
|
if normalized == "" || limit == 0 {
|
|
return Result{Query: query, NormalizedQuery: normalized, RootDir: store.RootDir, Hits: []Hit{}}, nil
|
|
}
|
|
sessionList, err := resolveSessions(store, strings.TrimSpace(options.SessionID))
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
terms := splitTerms(normalized)
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
result := Result{
|
|
Query: query,
|
|
NormalizedQuery: normalized,
|
|
RootDir: store.RootDir,
|
|
SearchedSessions: len(sessionList),
|
|
Hits: []Hit{},
|
|
}
|
|
for _, session := range sessionList {
|
|
index, err := LoadIndex(store, session, LoadOptions{Reindex: options.Reindex, Now: now})
|
|
if err != nil {
|
|
// A single corrupt or unreadable session must not abort the whole
|
|
// search surface; skip it and report the count.
|
|
result.SkippedSessions++
|
|
continue
|
|
}
|
|
for _, entry := range index.Entries {
|
|
if options.Type != "" && entry.Type != options.Type {
|
|
continue
|
|
}
|
|
match, ok := findMatch(entry.Text, normalized, terms)
|
|
if !ok {
|
|
continue
|
|
}
|
|
result.Hits = append(result.Hits, Hit{
|
|
Session: session,
|
|
Event: EventSummary{
|
|
ID: entry.EventID,
|
|
Sequence: entry.Sequence,
|
|
Type: entry.Type,
|
|
CreatedAt: entry.CreatedAt,
|
|
},
|
|
Context: buildContext(entry.Text, match.Start, match.End, contextChars),
|
|
Match: match,
|
|
})
|
|
if len(result.Hits) >= limit {
|
|
result.TotalHits = len(result.Hits)
|
|
return result, nil
|
|
}
|
|
}
|
|
}
|
|
result.TotalHits = len(result.Hits)
|
|
return result, nil
|
|
}
|
|
|
|
type LoadOptions struct {
|
|
Reindex bool
|
|
Now func() time.Time
|
|
}
|
|
|
|
func LoadIndex(store *sessions.Store, session sessions.Metadata, options LoadOptions) (Index, error) {
|
|
if !options.Reindex {
|
|
index, ok := readIndex(store, session)
|
|
if ok && current(index, session) {
|
|
return index, nil
|
|
}
|
|
}
|
|
return RebuildIndex(store, session, options.Now)
|
|
}
|
|
|
|
func RebuildIndex(store *sessions.Store, session sessions.Metadata, now func() time.Time) (Index, error) {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
events, err := store.ReadEvents(session.SessionID)
|
|
if err != nil {
|
|
return Index{}, err
|
|
}
|
|
index := Index{
|
|
SchemaVersion: indexSchema,
|
|
SessionID: session.SessionID,
|
|
SessionUpdatedAt: session.UpdatedAt,
|
|
SessionEventCount: session.EventCount,
|
|
GeneratedAt: now().UTC().Format(time.RFC3339),
|
|
Entries: make([]IndexEntry, 0, len(events)),
|
|
}
|
|
for _, event := range events {
|
|
index.Entries = append(index.Entries, IndexEntry{
|
|
SessionID: session.SessionID,
|
|
EventID: event.ID,
|
|
Sequence: event.Sequence,
|
|
Type: event.Type,
|
|
CreatedAt: event.CreatedAt,
|
|
Text: ExtractText(redactedPayload(event.Payload)),
|
|
})
|
|
}
|
|
data, err := json.MarshalIndent(index, "", " ")
|
|
if err != nil {
|
|
return Index{}, fmt.Errorf("encode Zero search index: %w", err)
|
|
}
|
|
if err := os.WriteFile(indexPath(store, session.SessionID), append(data, '\n'), 0o600); err != nil {
|
|
return Index{}, fmt.Errorf("write Zero search index: %w", err)
|
|
}
|
|
return index, nil
|
|
}
|
|
|
|
func FormatResult(result Result) string {
|
|
query := redaction.RedactString(strings.TrimSpace(result.Query), redaction.Options{})
|
|
if len(result.Hits) == 0 {
|
|
return fmt.Sprintf("No local session events matched %q. Searched %s.", query, count(result.SearchedSessions, "session"))
|
|
}
|
|
lines := []string{fmt.Sprintf("Found %s for %q:", count(result.TotalHits, "local session event"), query)}
|
|
for index, hit := range result.Hits {
|
|
title := ""
|
|
if hit.Session.Title != "" {
|
|
title = " - " + redaction.RedactString(hit.Session.Title, redaction.Options{})
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s #%d %s%s", index+1, redaction.RedactString(hit.Session.SessionID, redaction.Options{}), hit.Event.Sequence, redaction.RedactString(string(hit.Event.Type), redaction.Options{}), title))
|
|
context := strings.Join(strings.Fields(redaction.RedactString(hit.Context, redaction.Options{})), " ")
|
|
if context != "" {
|
|
lines = append(lines, " "+context)
|
|
}
|
|
details := []string{}
|
|
if hit.Session.Cwd != "" {
|
|
details = append(details, "cwd: "+redaction.RedactString(hit.Session.Cwd, redaction.Options{}))
|
|
}
|
|
if hit.Session.ModelID != "" {
|
|
details = append(details, "model: "+redaction.RedactString(hit.Session.ModelID, redaction.Options{}))
|
|
}
|
|
if hit.Session.Provider != "" {
|
|
details = append(details, "provider: "+redaction.RedactString(hit.Session.Provider, redaction.Options{}))
|
|
}
|
|
details = append(details, "updated: "+redaction.RedactString(hit.Session.UpdatedAt, redaction.Options{}))
|
|
lines = append(lines, " "+strings.Join(details, " | "))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func RedactResult(result Result) Result {
|
|
options := redaction.Options{}
|
|
redacted := result
|
|
redacted.Query = redaction.RedactString(redacted.Query, options)
|
|
redacted.NormalizedQuery = redaction.RedactString(redacted.NormalizedQuery, options)
|
|
redacted.RootDir = redaction.RedactString(redacted.RootDir, options)
|
|
redacted.Hits = make([]Hit, len(result.Hits))
|
|
for index, hit := range result.Hits {
|
|
redacted.Hits[index] = Hit{
|
|
Session: redactMetadata(hit.Session, options),
|
|
Event: redactEventSummary(hit.Event, options),
|
|
Context: redaction.RedactString(hit.Context, options),
|
|
Match: hit.Match,
|
|
}
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
func redactMetadata(session sessions.Metadata, options redaction.Options) sessions.Metadata {
|
|
session.SessionID = redaction.RedactString(session.SessionID, options)
|
|
session.Title = redaction.RedactString(session.Title, options)
|
|
session.Cwd = redaction.RedactString(session.Cwd, options)
|
|
session.ModelID = redaction.RedactString(session.ModelID, options)
|
|
session.Provider = redaction.RedactString(session.Provider, options)
|
|
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
|
|
session.ForkedFromEventID = redaction.RedactString(session.ForkedFromEventID, options)
|
|
session.CreatedAt = redaction.RedactString(session.CreatedAt, options)
|
|
session.UpdatedAt = redaction.RedactString(session.UpdatedAt, options)
|
|
session.LastEventType = sessions.EventType(redaction.RedactString(string(session.LastEventType), options))
|
|
return session
|
|
}
|
|
|
|
func redactEventSummary(event EventSummary, options redaction.Options) EventSummary {
|
|
event.ID = redaction.RedactString(event.ID, options)
|
|
event.Type = sessions.EventType(redaction.RedactString(string(event.Type), options))
|
|
event.CreatedAt = redaction.RedactString(event.CreatedAt, options)
|
|
return event
|
|
}
|
|
|
|
func NormalizeQuery(query string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(query), " "))
|
|
}
|
|
|
|
func ExtractText(value any) string {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return typed
|
|
case float64, float32, int, int64, bool:
|
|
return fmt.Sprint(typed)
|
|
case []any:
|
|
parts := []string{}
|
|
for _, item := range typed {
|
|
if text := ExtractText(item); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
case map[string]any:
|
|
parts := []string{}
|
|
keys := make([]string, 0, len(typed))
|
|
for key := range typed {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
parts = append(parts, key)
|
|
item := typed[key]
|
|
if text := ExtractText(item); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func resolveSessions(store *sessions.Store, sessionID string) ([]sessions.Metadata, error) {
|
|
if sessionID == "" {
|
|
return store.List()
|
|
}
|
|
session, err := store.Get(sessionID)
|
|
if err != nil {
|
|
return []sessions.Metadata{}, err
|
|
}
|
|
if session == nil {
|
|
// Surfacing the miss beats silently "succeeding" with zero results
|
|
// against a session that doesn't exist.
|
|
return []sessions.Metadata{}, fmt.Errorf("zero session not found: %s", sessionID)
|
|
}
|
|
return []sessions.Metadata{*session}, nil
|
|
}
|
|
|
|
func readIndex(store *sessions.Store, session sessions.Metadata) (Index, bool) {
|
|
data, err := os.ReadFile(indexPath(store, session.SessionID))
|
|
if err != nil {
|
|
return Index{}, false
|
|
}
|
|
var index Index
|
|
if err := json.Unmarshal(data, &index); err != nil {
|
|
return Index{}, false
|
|
}
|
|
return index, true
|
|
}
|
|
|
|
func current(index Index, session sessions.Metadata) bool {
|
|
return index.SchemaVersion == indexSchema &&
|
|
index.SessionID == session.SessionID &&
|
|
index.SessionUpdatedAt == session.UpdatedAt &&
|
|
index.SessionEventCount == session.EventCount &&
|
|
len(index.Entries) == session.EventCount
|
|
}
|
|
|
|
func redactedPayload(raw json.RawMessage) any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var payload any
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return redaction.RedactString(string(raw), redaction.Options{})
|
|
}
|
|
return redaction.RedactValue(payload, redaction.Options{})
|
|
}
|
|
|
|
// findMatch locates the query (or all terms) case-insensitively and returns
|
|
// byte offsets INTO THE ORIGINAL text. Matching happens on a lowered copy, but
|
|
// Unicode lowering can change byte lengths (İ → i̇, K → k), so lowered offsets
|
|
// are mapped back through a per-byte offset table — applying them to the
|
|
// original directly mis-slices runes and could even run past the end of the
|
|
// string (a panic in buildContext).
|
|
func findMatch(text string, query string, terms []string) (Match, bool) {
|
|
lowered, offsets := lowerWithOffsets(text)
|
|
mapBack := func(start, end int) Match {
|
|
return Match{Start: offsets[start], End: offsets[end]}
|
|
}
|
|
if index := strings.Index(lowered, query); index >= 0 {
|
|
return mapBack(index, index+len(query)), true
|
|
}
|
|
first := -1
|
|
last := -1
|
|
for _, term := range terms {
|
|
index := strings.Index(lowered, term)
|
|
if index < 0 {
|
|
return Match{}, false
|
|
}
|
|
if first < 0 || index < first {
|
|
first = index
|
|
}
|
|
if end := index + len(term); end > last {
|
|
last = end
|
|
}
|
|
}
|
|
if first < 0 {
|
|
return Match{}, false
|
|
}
|
|
return mapBack(first, last), true
|
|
}
|
|
|
|
// lowerWithOffsets lowers text rune-by-rune and returns, for every byte offset
|
|
// of the lowered string (inclusive of the end boundary), the corresponding
|
|
// byte offset in the original.
|
|
func lowerWithOffsets(text string) (string, []int) {
|
|
var lowered strings.Builder
|
|
lowered.Grow(len(text))
|
|
offsets := make([]int, 0, len(text)+1)
|
|
for index, glyph := range text {
|
|
lower := strings.ToLower(string(glyph))
|
|
for range len(lower) {
|
|
offsets = append(offsets, index)
|
|
}
|
|
lowered.WriteString(lower)
|
|
}
|
|
offsets = append(offsets, len(text))
|
|
return lowered.String(), offsets
|
|
}
|
|
|
|
func buildContext(text string, start int, end int, contextChars int) string {
|
|
left := snapRuneStart(text, start-contextChars)
|
|
right := snapRuneStart(text, end+contextChars)
|
|
return strings.TrimSpace(text[left:right])
|
|
}
|
|
|
|
// snapRuneStart clamps index into [0, len(text)] and walks it back to the
|
|
// nearest rune boundary so context slicing never emits invalid UTF-8.
|
|
func snapRuneStart(text string, index int) int {
|
|
if index <= 0 {
|
|
return 0
|
|
}
|
|
if index >= len(text) {
|
|
return len(text)
|
|
}
|
|
for index > 0 && !utf8.RuneStart(text[index]) {
|
|
index--
|
|
}
|
|
return index
|
|
}
|
|
|
|
func splitTerms(query string) []string {
|
|
if query == "" {
|
|
return nil
|
|
}
|
|
return strings.Fields(query)
|
|
}
|
|
|
|
func normalizeLimit(limit int) int {
|
|
if limit == 0 {
|
|
return defaultLimit
|
|
}
|
|
if limit < 0 {
|
|
return 0
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func normalizeContext(contextChars int) int {
|
|
if contextChars == 0 {
|
|
return defaultContext
|
|
}
|
|
if contextChars < 0 {
|
|
return 0
|
|
}
|
|
return contextChars
|
|
}
|
|
|
|
func indexPath(store *sessions.Store, sessionID string) string {
|
|
return filepath.Join(store.RootDir, sessionID, IndexFileName)
|
|
}
|
|
|
|
func count(value int, label string) string {
|
|
suffix := "s"
|
|
if value == 1 {
|
|
suffix = ""
|
|
}
|
|
return fmt.Sprintf("%d %s%s", value, label, suffix)
|
|
}
|