Files
zero/internal/tools/ask_user.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

183 lines
6.8 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
)
// AskUserQuestion is one clarifying question the agent wants the user to answer.
// Options/MultiSelect are presentation hints for an interactive front-end; the
// tool itself never blocks on input.
type AskUserQuestion struct {
Question string
Options []string
MultiSelect bool
}
// askUserNonInteractiveMessage is returned both by the tool's own Run() fallback
// and by the agent loop when no interactive user is wired up, so the model gets
// identical, actionable guidance in either path.
const askUserNonInteractiveMessage = "No interactive user is available to answer questions. " +
"Proceed with your best assumption, explicitly stating the assumptions you are making."
type askUserTool struct {
baseTool
}
// NewAskUserTool builds the ask_user tool. It is read-only (it gathers input,
// never mutates the workspace). The agent loop intercepts ask_user calls and
// routes them to an interactive front-end when one exists; this tool's Run() is
// the fallback used when nothing intercepts the call (e.g. headless runs).
func NewAskUserTool() *askUserTool {
return &askUserTool{
baseTool: baseTool{
name: "ask_user",
description: "Ask the user one or more clarifying questions and wait for their answers. " +
"Use ONLY for genuinely blocking ambiguity that you cannot resolve from the workspace or reasonable assumptions. " +
"If no interactive user is available, this returns guidance to proceed with your best assumption instead of blocking.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"header": {
Type: "string",
Description: "Optional short heading shown above the questions.",
},
"questions": {
Type: "array",
Description: "One or more questions to ask the user.",
MinItems: intPtr(1),
Items: &PropertySchema{
Type: "object",
Properties: map[string]PropertySchema{
"question": {Type: "string", Description: "The non-empty question to ask the user.", MinLength: intPtr(1)},
"options": {
Type: "array",
Description: "Optional list of suggested answer choices.",
Items: &PropertySchema{Type: "string"},
},
"multiSelect": {
Type: "boolean",
Description: "Whether multiple options may be selected (defaults to false).",
},
},
Required: []string{"question"},
},
},
},
Required: []string{"questions"},
AdditionalProperties: false,
},
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
},
}
}
// Run is the fallback path: it is only reached when nothing intercepted the call
// (no interactive user). It validates the arguments so a malformed call still
// gets useful feedback, then tells the model to proceed with its best assumption.
// It never blocks on input.
func (tool *askUserTool) Run(_ context.Context, args map[string]any) Result {
if _, err := ParseAskUserQuestions(args); err != nil {
return errorResult("Error: Invalid arguments for ask_user: " + err.Error())
}
return okResult(askUserNonInteractiveMessage)
}
// AskUserNonInteractiveMessage exposes the shared graceful-degradation message so
// the agent loop and the tool fallback stay in lock-step.
func AskUserNonInteractiveMessage() string {
return askUserNonInteractiveMessage
}
// ParseAskUserQuestions extracts the questionnaire from raw tool arguments. It is
// shared by the tool's Run() fallback and the agent loop's interactive path so
// both validate identically.
func ParseAskUserQuestions(args map[string]any) ([]AskUserQuestion, error) {
raw, ok := args["questions"]
if !ok || raw == nil {
return nil, fmt.Errorf("questions is required")
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("questions must be an array")
}
if len(items) == 0 {
return nil, fmt.Errorf("questions must contain at least one question")
}
questions := make([]AskUserQuestion, 0, len(items))
for index, item := range items {
// Weak models sometimes send a question as a bare string instead of an
// object — accept that as a free-text question.
if text, ok := item.(string); ok {
if strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("question %d must not be empty", index+1)
}
questions = append(questions, AskUserQuestion{Question: text})
continue
}
object, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("question %d must be an object or string", index+1)
}
text, err := questionTextArg(object)
if err != nil {
return nil, fmt.Errorf("question %d %s", index+1, err.Error())
}
// multiSelect is a UI hint; treat an uncoercible value as the default rather
// than failing the whole call (mirrors the best-effort options path).
multiSelect, _ := boolArg(object, "multiSelect", false)
questions = append(questions, AskUserQuestion{
Question: text,
Options: coerceOptionStrings(object["options"]), // best-effort; never errors
MultiSelect: multiSelect,
})
}
return questions, nil
}
// questionTextArg reads the question text, accepting common key variants used by
// weaker models. It enforces a non-empty trimmed string but, unlike
// aliasedStringArg, treats a present-but-non-string or blank value as "not
// present" and falls through to the next alias (question text is best-effort
// across spellings, not type-strict).
func questionTextArg(object map[string]any) (string, error) {
for _, key := range []string{"question", "prompt", "text", "q", "title"} {
if v, ok := object[key]; ok && v != nil {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return s, nil
}
}
}
return "", fmt.Errorf("question is required")
}
// coerceOptionStrings turns whatever a model put in "options" into a string slice
// without ever failing — options are presentation hints, so a malformed shape must
// not break the whole ask_user call. It delegates to the shared coerceStringSlice
// so there is a single coercion path across the package.
func coerceOptionStrings(value any) []string {
return coerceStringSlice(value)
}
// FormatAskUserAnswers renders question/answer pairs into a clear, model-readable
// block. Missing answers are surfaced explicitly so the model never silently
// treats an unanswered question as answered.
func FormatAskUserAnswers(questions []AskUserQuestion, answers []string) string {
lines := make([]string, 0, len(questions)*3)
for index, question := range questions {
answer := ""
if index < len(answers) {
answer = strings.TrimSpace(answers[index])
}
if answer == "" {
answer = "(no answer provided)"
}
lines = append(lines, fmt.Sprintf("%d. [question] %s", index+1, question.Question))
lines = append(lines, "[answer] "+answer)
lines = append(lines, "")
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}