Files
zero/internal/tui/commands.go
Vasanthdev2004 4bc9a97176 feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation
Second slice of #501. Adds the controller and wires it in:

- /loop command registered (commands.go) and dispatched to handleLoopCommand.
- Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards,
  loopNextWake/loopDone self-pace channel).
- An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the
  session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a
  user's queued prompt never collide; a self-perpetuating tea.Tick that stops when
  no loops remain.
- The agentResponseMsg completion seam advances a loop iteration (schedule next
  wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and
  done stop conditions, restarting the ticker if other loops remain.
- /loop list|stop|stop all, footer summary + list rendering, and a guard that only
  a prompt or custom /command may loop (not a built-in).

Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
2026-07-06 08:53:04 +05:30

538 lines
13 KiB
Go

package tui
import (
"strings"
)
type commandKind int
const (
commandEmpty commandKind = iota
commandPrompt
commandHelp
commandClear
commandExit
commandTools
commandMCP
commandPermissions
commandPS
commandStop
commandSandboxSetup
commandProvider
commandModel
commandContext
commandConfig
commandDebug
commandDoctor
commandPlan
commandSearch
commandResume
commandRetitle
commandSpec
commandInit
commandCompact
commandRewind
commandEffort
commandStyle
commandTheme
commandTranscript
commandBash
commandImage
commandAddDir
commandSelfCorrect
commandTurns
commandRetry
commandEdit
commandCopy
commandExport
commandNew
commandSkills
commandLoop
commandUnknown
)
type commandGroup string
const (
commandGroupSession commandGroup = "session"
commandGroupModel commandGroup = "model"
commandGroupRuntime commandGroup = "runtime"
commandGroupTools commandGroup = "tools"
commandGroupMeta commandGroup = "meta"
)
type commandDefinition struct {
name string
aliases []string
usage string
group commandGroup
description string
kind commandKind
}
type parsedCommand struct {
kind commandKind
text string
name string
}
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider [status]",
group: commandGroupModel,
description: "Open provider setup.",
kind: commandProvider,
},
{
name: "/model",
usage: "/model [list|id]",
group: commandGroupModel,
description: "Show or switch the active model.",
kind: commandModel,
},
{
name: "/plan",
usage: "/plan",
group: commandGroupSession,
description: "Show planning mode status.",
kind: commandPlan,
},
{
name: "/permissions",
usage: "/permissions",
group: commandGroupRuntime,
description: "Show the active permission mode and sandbox grants.",
kind: commandPermissions,
},
{
name: "/ps",
usage: "/ps",
group: commandGroupRuntime,
description: "List running background terminal sessions.",
kind: commandPS,
},
{
name: "/stop",
usage: "/stop [session_id]",
group: commandGroupRuntime,
description: "Stop running background terminal sessions.",
kind: commandStop,
},
{
name: "/sandbox-setup",
usage: "/sandbox-setup",
group: commandGroupRuntime,
description: "Run native sandbox setup for this platform.",
kind: commandSandboxSetup,
},
{
name: "/tools",
usage: "/tools",
group: commandGroupTools,
description: "List registered tools.",
kind: commandTools,
},
{
name: "/skills",
usage: "/skills",
group: commandGroupTools,
description: "List installed skills; run one directly with /<skill-name> [args].",
kind: commandSkills,
},
{
name: "/context",
usage: "/context",
group: commandGroupSession,
description: "Show current workspace and runtime context.",
kind: commandContext,
},
{
name: "/image",
usage: "/image <path> | clear",
group: commandGroupSession,
description: "Attach a local image (vision models) or PDF (text layer for any model) to the next message. /image clear removes pending attachments.",
kind: commandImage,
},
{
name: "/add-dir",
usage: "/add-dir [path]",
group: commandGroupRuntime,
description: "Grant write access to a directory outside the workspace for this session; bare form lists current write roots.",
kind: commandAddDir,
},
{
name: "/clear",
usage: "/clear",
group: commandGroupMeta,
description: "Clear the visible transcript.",
kind: commandClear,
},
{
name: "/new",
usage: "/new",
group: commandGroupSession,
description: "Start a fresh session; the current one stays resumable via /resume.",
kind: commandNew,
},
{
name: "/search",
aliases: []string{"/find"},
usage: "/search <query>",
group: commandGroupTools,
description: "Search local session events. Requires a query argument.",
kind: commandSearch,
},
{
name: "/mcp",
aliases: []string{"/mcp-status"},
usage: "/mcp",
group: commandGroupTools,
description: "Show MCP server status.",
kind: commandMCP,
},
{
name: "/resume",
aliases: []string{"/sessions"},
usage: "/resume [id]",
group: commandGroupSession,
description: "List recent sessions or show resume guidance.",
kind: commandResume,
},
{
name: "/retitle",
usage: "/retitle",
group: commandGroupSession,
description: "Generate concise titles for resumable sessions.",
kind: commandRetitle,
},
{
name: "/spec",
usage: "/spec <task>",
group: commandGroupSession,
description: "Draft an implementation spec for review before editing.",
kind: commandSpec,
},
{
name: "/init",
usage: "/init",
group: commandGroupSession,
description: "Investigate the repo and generate an AGENTS.md for the agent.",
kind: commandInit,
},
{
name: "/compact",
usage: "/compact [status|now]",
group: commandGroupSession,
description: "Compact the transcript now, or show compaction state (/compact status).",
kind: commandCompact,
},
{
name: "/transcript",
usage: "/transcript",
group: commandGroupSession,
description: "Toggle the detailed transcript view.",
kind: commandTranscript,
},
{
name: "/rewind",
usage: "/rewind [latest|<sequence>]",
group: commandGroupSession,
description: "Restore workspace files to a checkpoint and truncate the session.",
kind: commandRewind,
},
{
name: "/effort",
usage: "/effort [list|low|medium|high|auto]",
group: commandGroupModel,
description: "Show or set reasoning effort for supported models.",
kind: commandEffort,
},
{
name: "/style",
usage: "/style [list|balanced|concise|explanatory|review]",
group: commandGroupSession,
description: "Show or set the response style preference.",
kind: commandStyle,
},
{
name: "/selfcorrect",
aliases: []string{"/sc"},
usage: "/selfcorrect [status|on|off|tests|full|lsp]",
group: commandGroupSession,
description: "Show or set post-edit self-correction depth (LSP-only default; on/tests/full add the project test plan; off/lsp disable tests, LSP-only).",
kind: commandSelfCorrect,
},
{
name: "/turns",
usage: "/turns [n]",
group: commandGroupSession,
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
kind: commandTurns,
},
{
name: "/retry",
usage: "/retry",
group: commandGroupSession,
description: "Resend your last prompt.",
kind: commandRetry,
},
{
name: "/edit",
usage: "/edit",
group: commandGroupSession,
description: "Recall your last prompt into the composer to edit and resend.",
kind: commandEdit,
},
{
name: "/copy",
usage: "/copy",
group: commandGroupSession,
description: "Copy the last answer to the clipboard.",
kind: commandCopy,
},
{
name: "/export",
usage: "/export [path]",
group: commandGroupSession,
description: "Write the conversation transcript to a file.",
kind: commandExport,
},
{
name: "/loop",
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
group: commandGroupSession,
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/help",
usage: "/help",
group: commandGroupMeta,
description: "Show available commands.",
kind: commandHelp,
},
{
name: "/doctor",
aliases: []string{"/health"},
usage: "/doctor [fix|--connectivity]",
group: commandGroupRuntime,
description: "Show diagnostics, open safe fixes, or run provider connectivity checks.",
kind: commandDoctor,
},
{
name: "/config",
usage: "/config [recaps on|off]",
group: commandGroupRuntime,
description: "Show active configuration; toggle settings (recaps on|off).",
kind: commandConfig,
},
{
name: "/debug",
aliases: []string{"/debug-mode"},
usage: "/debug",
group: commandGroupRuntime,
description: "Show debug mode status.",
kind: commandDebug,
},
{
name: "/theme",
usage: "/theme [list|auto|name]",
group: commandGroupSession,
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
kind: commandTheme,
},
{
name: "/exit",
aliases: []string{"/quit"},
usage: "/exit",
group: commandGroupMeta,
description: "Exit Zero.",
kind: commandExit,
},
}
func parseCommand(input string) parsedCommand {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return parsedCommand{kind: commandEmpty}
}
// "!cmd" is a shell escape (the footer advertises "! bash"): run it directly.
if strings.HasPrefix(trimmed, "!") {
return parsedCommand{kind: commandBash, text: strings.TrimSpace(trimmed[1:])}
}
if strings.HasPrefix(trimmed, "/") {
name, args := splitCommand(trimmed)
command, ok := resolveCommand(name)
if ok {
return parsedCommand{kind: command.kind, name: command.name, text: args}
}
return parsedCommand{kind: commandUnknown, text: trimmed}
}
return parsedCommand{kind: commandPrompt, text: trimmed}
}
func splitCommand(input string) (string, string) {
parts := strings.Fields(input)
if len(parts) == 0 {
return "", ""
}
name := parts[0]
args := strings.TrimSpace(input[len(name):])
return strings.ToLower(name), args
}
func resolveCommand(name string) (commandDefinition, bool) {
normalized := strings.ToLower(strings.TrimSpace(name))
for _, command := range commandDefinitions {
if normalized == command.name {
return command, true
}
for _, alias := range command.aliases {
if normalized == alias {
return command, true
}
}
}
return commandDefinition{}, false
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
names = append(names, command.name)
names = append(names, command.aliases...)
}
return names
}
func formatCommandHelpLines() []string {
return formatGroupedCommandHelpLines()
}
func formatGroupedCommandHelpLines() []string {
lines := make([]string, 0, len(commandDefinitions)+len(commandGroupOrder()))
for _, group := range commandGroupOrder() {
groupLines := commandHelpLinesForGroup(group)
if len(groupLines) == 0 {
continue
}
lines = append(lines, string(group)+":")
lines = append(lines, groupLines...)
}
return lines
}
func formatGroupedCommandHelp() string {
lines := []string{"Commands", "status: info"}
for _, group := range commandGroupOrder() {
groupLines := commandHelpLinesForGroup(group)
if len(groupLines) == 0 {
continue
}
lines = append(lines, commandGroupTitle(group))
lines = append(lines, groupLines...)
}
lines = append(lines, "hint: submit plain text to ask Zero")
return strings.Join(lines, "\n")
}
func commandHelpLinesForGroup(group commandGroup) []string {
lines := []string{}
for _, command := range commandDefinitions {
if command.group != group {
continue
}
lines = append(lines, " "+formatCommandHelpLine(command))
}
return lines
}
func formatCommandHelpLine(command commandDefinition) string {
label := command.usage
if len(command.aliases) > 0 {
label += " (" + strings.Join(command.aliases, ", ") + ")"
}
return label + " - " + command.description
}
func commandSelectionRequiresInput(name string) bool {
command, ok := resolveCommand(name)
return ok && commandUsageRequiresInput(command.usage)
}
func commandRequiredInputHint(name string) string {
command, ok := resolveCommand(name)
if !ok {
return ""
}
placeholder := commandUsageRequiredPlaceholder(command.usage)
if placeholder == "" {
return ""
}
return "[" + placeholder + "]"
}
func commandUsageRequiresInput(usage string) bool {
return commandUsageRequiredPlaceholder(usage) != ""
}
func commandUsageRequiredPlaceholder(usage string) string {
optionalDepth := 0
var placeholder strings.Builder
inPlaceholder := false
for _, char := range usage {
if inPlaceholder {
if char == '>' {
return strings.TrimSpace(placeholder.String())
}
placeholder.WriteRune(char)
continue
}
switch char {
case '[':
optionalDepth++
case ']':
if optionalDepth > 0 {
optionalDepth--
}
case '<':
if optionalDepth == 0 {
inPlaceholder = true
}
}
}
return ""
}
func commandGroupOrder() []commandGroup {
return []commandGroup{
commandGroupModel,
commandGroupSession,
commandGroupRuntime,
commandGroupTools,
commandGroupMeta,
}
}
func commandGroupTitle(group commandGroup) string {
switch group {
case commandGroupModel:
return "Model"
case commandGroupSession:
return "Session"
case commandGroupRuntime:
return "Runtime"
case commandGroupTools:
return "Tools"
case commandGroupMeta:
return "Meta"
default:
return string(group)
}
}