mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -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]>
177 lines
5.7 KiB
Go
177 lines
5.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestAskUserToolSafetyIsReadOnly(t *testing.T) {
|
|
safety := NewAskUserTool().Safety()
|
|
if safety.Permission != PermissionAllow {
|
|
t.Fatalf("expected ask_user to be permission=allow, got %q", safety.Permission)
|
|
}
|
|
if safety.SideEffect != SideEffectRead {
|
|
t.Fatalf("expected ask_user side effect read, got %q", safety.SideEffect)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolAdvertisesQuestionSchema(t *testing.T) {
|
|
schema := NewAskUserTool().Parameters()
|
|
questions, ok := schema.Properties["questions"]
|
|
if !ok {
|
|
t.Fatal("expected ask_user to advertise a questions property")
|
|
}
|
|
if questions.Type != "array" || questions.Items == nil {
|
|
t.Fatalf("expected questions to be an array with item schema, got %#v", questions)
|
|
}
|
|
if questions.Items.Type != "object" {
|
|
t.Fatalf("expected question items to be objects, got %q", questions.Items.Type)
|
|
}
|
|
if _, ok := questions.Items.Properties["question"]; !ok {
|
|
t.Fatal("expected question item to document a question field")
|
|
}
|
|
if _, ok := questions.Items.Properties["options"]; !ok {
|
|
t.Fatal("expected question item to document an options field")
|
|
}
|
|
if _, ok := questions.Items.Properties["multiSelect"]; !ok {
|
|
t.Fatal("expected question item to document a multiSelect field")
|
|
}
|
|
requiredQuestion := false
|
|
for _, name := range questions.Items.Required {
|
|
if name == "question" {
|
|
requiredQuestion = true
|
|
}
|
|
}
|
|
if !requiredQuestion {
|
|
t.Fatalf("expected question field to be required, got %v", questions.Items.Required)
|
|
}
|
|
requiredQuestions := false
|
|
for _, name := range schema.Required {
|
|
if name == "questions" {
|
|
requiredQuestions = true
|
|
}
|
|
}
|
|
if !requiredQuestions {
|
|
t.Fatalf("expected questions to be required, got %v", schema.Required)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolAdvertisesNonEmptyQuestionSchema(t *testing.T) {
|
|
data, err := json.Marshal(NewAskUserTool().Parameters())
|
|
if err != nil {
|
|
t.Fatalf("marshal schema: %v", err)
|
|
}
|
|
schema := string(data)
|
|
for _, want := range []string{`"minItems":1`, `"minLength":1`} {
|
|
if !strings.Contains(schema, want) {
|
|
t.Fatalf("ask_user schema missing %s: %s", want, schema)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolRunFallsBackWhenNonInteractive(t *testing.T) {
|
|
// The tool's own Run() is only reached when nothing intercepted the call,
|
|
// i.e. there is no interactive user. It must degrade gracefully, never block.
|
|
result := NewAskUserTool().Run(context.Background(), map[string]any{
|
|
"questions": []any{
|
|
map[string]any{"question": "Which framework?"},
|
|
},
|
|
})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status from graceful fallback, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "no interactive user") {
|
|
t.Fatalf("expected no-interactive-user message, got %q", result.Output)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "assumption") {
|
|
t.Fatalf("expected guidance to proceed with assumptions, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolRunRejectsMissingQuestions(t *testing.T) {
|
|
result := NewAskUserTool().Run(context.Background(), map[string]any{
|
|
"questions": []any{},
|
|
})
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status when no questions provided, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "at least one question") {
|
|
t.Fatalf("unexpected output: %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestCoreReadOnlyToolsIncludeAskUser(t *testing.T) {
|
|
found := false
|
|
for _, tool := range CoreReadOnlyTools(t.TempDir()) {
|
|
if tool.Name() == "ask_user" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected ask_user in core read-only tools")
|
|
}
|
|
}
|
|
|
|
func TestParseAskUserQuestionsLenientOptions(t *testing.T) {
|
|
// minimax-style: options as array of objects with a label field.
|
|
qs, err := ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{
|
|
map[string]any{"question": "Pick a style", "options": []any{
|
|
map[string]any{"label": "Modern"},
|
|
map[string]any{"value": "Classic"},
|
|
"Minimal",
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("objects/strings options must not error: %v", err)
|
|
}
|
|
if got := qs[0].Options; len(got) != 3 || got[0] != "Modern" || got[1] != "Classic" || got[2] != "Minimal" {
|
|
t.Fatalf("coerced options = %v, want [Modern Classic Minimal]", got)
|
|
}
|
|
|
|
// options as a single newline-delimited string.
|
|
qs, err = ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "q", "options": "A\nB"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("string options must not error: %v", err)
|
|
}
|
|
if got := qs[0].Options; len(got) != 2 || got[0] != "A" || got[1] != "B" {
|
|
t.Fatalf("string-split options = %v, want [A B]", got)
|
|
}
|
|
|
|
// no options at all = valid free-text question.
|
|
if _, err := ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "free text?"}},
|
|
}); err != nil {
|
|
t.Fatalf("missing options must be allowed: %v", err)
|
|
}
|
|
|
|
// multiSelect is a UI hint: an uncoercible value must NOT fail the call; it
|
|
// defaults to false (best-effort, like options).
|
|
qs, err = ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "q", "multiSelect": "maybe"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("uncoercible multiSelect must not error: %v", err)
|
|
}
|
|
if qs[0].MultiSelect {
|
|
t.Fatalf("uncoercible multiSelect should default to false")
|
|
}
|
|
}
|
|
|
|
func TestParseAskUserQuestionsStringItem(t *testing.T) {
|
|
// minimax-style: a question item that is a bare string, not an object.
|
|
qs, err := ParseAskUserQuestions(map[string]any{"questions": []any{"What is your name?"}})
|
|
if err != nil {
|
|
t.Fatalf("string question item must not error: %v", err)
|
|
}
|
|
if len(qs) != 1 || qs[0].Question != "What is your name?" {
|
|
t.Fatalf("bare-string question = %+v", qs)
|
|
}
|
|
}
|