mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-13 06:51:27 -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
9.2 KiB
Go
319 lines
9.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
type rowKind int
|
|
|
|
const (
|
|
rowWelcome rowKind = iota
|
|
rowUser
|
|
rowAssistant
|
|
rowToolCall
|
|
rowToolResult
|
|
rowPermission
|
|
rowAskUser
|
|
rowSystem
|
|
rowError
|
|
)
|
|
|
|
type transcriptRow struct {
|
|
kind rowKind
|
|
id string
|
|
text string
|
|
tool string // tool name, for tool call/result rows
|
|
status tools.Status // result status, for tool result rows
|
|
detail string // raw multi-line output (e.g. a diff to render as a card)
|
|
arg string // secondary argument hint (pattern/command), for tool call rows
|
|
runID int // owning run, for tool call rows (0 = rehydrated/unknown)
|
|
permission *agent.PermissionEvent
|
|
askUser *agent.AskUserRequest
|
|
|
|
// Final-answer metadata, set at append time. Interim assistant text streams
|
|
// through model.streamingText and never lands in the transcript, so a
|
|
// rowAssistant marked final IS the turn's answer — the renderer must not
|
|
// re-parse text to tell the two apart. turnTools/turnElapsed feed the done
|
|
// line; zero values mean "unknown" and the segment is omitted.
|
|
final bool
|
|
turnTools int
|
|
turnElapsed time.Duration
|
|
}
|
|
|
|
type transcriptActionKind int
|
|
|
|
const (
|
|
actionAppendUser transcriptActionKind = iota
|
|
actionAppendAssistant
|
|
actionAppendSystem
|
|
actionAppendError
|
|
actionClear
|
|
)
|
|
|
|
type transcriptAction struct {
|
|
kind transcriptActionKind
|
|
text string
|
|
}
|
|
|
|
func initialTranscript() []transcriptRow {
|
|
return []transcriptRow{{
|
|
kind: rowWelcome,
|
|
text: "Welcome to Zero. Type /help for commands.",
|
|
}}
|
|
}
|
|
|
|
func reduceTranscript(rows []transcriptRow, action transcriptAction) []transcriptRow {
|
|
switch action.kind {
|
|
case actionClear:
|
|
return initialTranscript()
|
|
case actionAppendUser:
|
|
return appendRow(rows, rowUser, action.text)
|
|
case actionAppendAssistant:
|
|
return appendRow(rows, rowAssistant, action.text)
|
|
case actionAppendSystem:
|
|
return appendRow(rows, rowSystem, action.text)
|
|
case actionAppendError:
|
|
return appendRow(rows, rowError, action.text)
|
|
default:
|
|
return rows
|
|
}
|
|
}
|
|
|
|
func appendRow(rows []transcriptRow, kind rowKind, text string) []transcriptRow {
|
|
return appendTranscriptRow(rows, transcriptRow{kind: kind, text: text})
|
|
}
|
|
|
|
func appendTranscriptRow(rows []transcriptRow, row transcriptRow) []transcriptRow {
|
|
if hasTranscriptRow(rows, row) {
|
|
return rows
|
|
}
|
|
// In-place append is safe: every transcript mutation happens on the Bubble
|
|
// Tea update goroutine (agent goroutines only Send messages), so no other
|
|
// model copy can append into the same backing array concurrently. The old
|
|
// full-slice copy made appends O(n) and rehydration O(n²).
|
|
return append(rows, row)
|
|
}
|
|
|
|
func hasTranscriptRow(rows []transcriptRow, row transcriptRow) bool {
|
|
key := transcriptRowKey(row)
|
|
if key == "" {
|
|
return false
|
|
}
|
|
for _, existing := range rows {
|
|
if transcriptRowKey(existing) == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// transcriptRowKey is run-scoped (runID baked into every key): some providers
|
|
// synthesize ToolCallIDs that repeat across runs (e.g. Gemini's gemini_tool_N),
|
|
// and a bare-id key silently dropped later runs' tool rows as "duplicates".
|
|
// Repeats WITHIN one run are disambiguated upstream by the per-run ordinal
|
|
// suffix the runner appends to row ids (see effectiveToolRowID).
|
|
func transcriptRowKey(row transcriptRow) string {
|
|
switch row.kind {
|
|
case rowToolCall, rowToolResult:
|
|
if row.id != "" {
|
|
return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id)
|
|
}
|
|
case rowPermission:
|
|
if row.permission != nil && row.permission.ToolCallID != "" {
|
|
return fmt.Sprintf("%d:%d:%s:%s", row.kind, row.runID, row.permission.ToolCallID, row.permission.Action)
|
|
}
|
|
case rowAskUser:
|
|
// Prefer row.id (set to the ToolCallID): it survives rehydration even when
|
|
// row.askUser is nil, so a reloaded ask_user row still dedupes correctly.
|
|
if row.id != "" {
|
|
return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id)
|
|
}
|
|
if row.askUser != nil && row.askUser.ToolCallID != "" {
|
|
return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.askUser.ToolCallID)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// effectiveToolRowID disambiguates a provider tool-call id that repeats within
|
|
// a run: the first occurrence keeps the raw id (the common case), repeats get
|
|
// an ordinal suffix. Session payloads are unaffected — they persist the
|
|
// provider's original ids.
|
|
func effectiveToolRowID(id string, seq int) string {
|
|
if id == "" || seq <= 1 {
|
|
return id
|
|
}
|
|
return fmt.Sprintf("%s#%d", id, seq)
|
|
}
|
|
|
|
func askUserTranscriptRow(request agent.AskUserRequest) transcriptRow {
|
|
return transcriptRow{
|
|
kind: rowAskUser,
|
|
id: request.ToolCallID,
|
|
text: askUserRowText(request),
|
|
detail: askUserDetailText(request),
|
|
askUser: &request,
|
|
}
|
|
}
|
|
|
|
func askUserRowText(request agent.AskUserRequest) string {
|
|
parts := []string{"ask_user:"}
|
|
if header := strings.TrimSpace(request.Header); header != "" {
|
|
parts = append(parts, header)
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("%d question(s)", len(request.Questions)))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func askUserDetailText(request agent.AskUserRequest) string {
|
|
lines := make([]string, 0, len(request.Questions))
|
|
for index, question := range request.Questions {
|
|
line := fmt.Sprintf("%d. %s", index+1, question.Question)
|
|
if len(question.Options) > 0 {
|
|
line += " (" + strings.Join(question.Options, ", ") + ")"
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func askUserSessionPayload(request agent.AskUserRequest) map[string]any {
|
|
questions := make([]map[string]any, 0, len(request.Questions))
|
|
for _, question := range request.Questions {
|
|
entry := map[string]any{"question": question.Question}
|
|
if len(question.Options) > 0 {
|
|
entry["options"] = question.Options
|
|
}
|
|
if question.MultiSelect {
|
|
entry["multiSelect"] = true
|
|
}
|
|
questions = append(questions, entry)
|
|
}
|
|
payload := map[string]any{
|
|
"role": "ask_user",
|
|
"toolCallId": request.ToolCallID,
|
|
"questions": questions,
|
|
}
|
|
if header := strings.TrimSpace(request.Header); header != "" {
|
|
payload["header"] = header
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func permissionTranscriptRow(event agent.PermissionEvent) transcriptRow {
|
|
return transcriptRow{
|
|
kind: rowPermission,
|
|
id: event.ToolCallID,
|
|
text: permissionRowText(event),
|
|
tool: event.ToolName,
|
|
detail: permissionDetailText(event),
|
|
permission: &event,
|
|
}
|
|
}
|
|
|
|
func permissionEventFromRequest(request agent.PermissionRequest) agent.PermissionEvent {
|
|
return agent.PermissionEvent{
|
|
ToolCallID: request.ToolCallID,
|
|
ToolName: request.ToolName,
|
|
Action: request.Action,
|
|
Permission: request.Permission,
|
|
PermissionMode: request.PermissionMode,
|
|
Autonomy: request.Autonomy,
|
|
SideEffect: request.SideEffect,
|
|
Reason: request.Reason,
|
|
Risk: request.Risk,
|
|
Violation: request.Violation,
|
|
GrantMatched: request.GrantMatched,
|
|
Grant: request.Grant,
|
|
}
|
|
}
|
|
|
|
func permissionRowText(event agent.PermissionEvent) string {
|
|
parts := []string{"permission:"}
|
|
if event.ToolName != "" {
|
|
parts = append(parts, event.ToolName)
|
|
}
|
|
if event.Action != "" {
|
|
parts = append(parts, string(event.Action))
|
|
}
|
|
if event.Risk.Level != "" {
|
|
parts = append(parts, "risk:"+string(event.Risk.Level))
|
|
}
|
|
if event.Violation != nil && event.Violation.Code != "" {
|
|
parts = append(parts, "violation:"+string(event.Violation.Code))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func permissionDetailText(event agent.PermissionEvent) string {
|
|
parts := []string{}
|
|
if event.Permission != "" {
|
|
parts = append(parts, "permission="+event.Permission)
|
|
}
|
|
if event.PermissionMode != "" {
|
|
parts = append(parts, "mode="+string(event.PermissionMode))
|
|
}
|
|
if event.Autonomy != "" {
|
|
parts = append(parts, "autonomy="+event.Autonomy)
|
|
}
|
|
if event.SideEffect != "" {
|
|
parts = append(parts, "side_effect="+event.SideEffect)
|
|
}
|
|
if event.Risk.Level != "" {
|
|
parts = append(parts, "risk="+string(event.Risk.Level))
|
|
}
|
|
if event.GrantMatched {
|
|
parts = append(parts, "grant=matched")
|
|
}
|
|
if event.Reason != "" {
|
|
parts = append(parts, event.Reason)
|
|
}
|
|
if event.Violation != nil {
|
|
violation := "violation=" + string(event.Violation.Code)
|
|
if event.Violation.Risk.Level != "" {
|
|
violation += " risk=" + string(event.Violation.Risk.Level)
|
|
}
|
|
if event.Violation.Path != "" {
|
|
violation += " path=" + event.Violation.Path
|
|
}
|
|
if event.Violation.Reason != "" {
|
|
violation += " " + event.Violation.Reason
|
|
}
|
|
parts = append(parts, violation)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func truncateTUIOutput(output string, limit int) string {
|
|
output = strings.TrimSpace(strings.ReplaceAll(output, "\r\n", "\n"))
|
|
output = strings.ReplaceAll(output, "\n", " ")
|
|
if limit <= 0 || len(output) <= limit {
|
|
return output
|
|
}
|
|
// Cut on a rune boundary: a bare byte slice can split a multi-byte UTF-8
|
|
// sequence and emit invalid UTF-8 into the transcript and session log.
|
|
return cutRunes(output, limit) + " [truncated]"
|
|
}
|
|
|
|
// cutRunes truncates text to at most limit bytes without splitting a UTF-8
|
|
// rune (the cut lands on the last rune boundary at or before limit).
|
|
func cutRunes(text string, limit int) string {
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
if len(text) <= limit {
|
|
return text
|
|
}
|
|
for limit > 0 && !utf8.RuneStart(text[limit]) {
|
|
limit--
|
|
}
|
|
return text[:limit]
|
|
}
|