mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
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.
538 lines
13 KiB
Go
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)
|
|
}
|
|
}
|