mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9a3e4aeda | ||
|
|
82ecac5281 | ||
|
|
387fe67ee7 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.1.0"}
|
||||
{".":"0.2.0"}
|
||||
@@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
|
||||
@@ -731,6 +731,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -297,6 +298,13 @@ var commandDefinitions = []commandDefinition{
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The /loop command repeats a prompt or slash command in one of two modes:
|
||||
//
|
||||
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
|
||||
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
|
||||
// does one increment of work each iteration and ends its reply with a control
|
||||
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
|
||||
// the next wake. With no control line it falls back to an adaptive cadence.
|
||||
//
|
||||
// Loops are foreground and session-scoped: a tick only fires while the session is
|
||||
// idle between turns (it never interrupts a streaming turn), the loop lives with the
|
||||
// session that created it, and switching sessions (/new, /resume) stops it. Safety
|
||||
// rails cap iterations, age, consecutive failures, and no-progress repetition.
|
||||
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
|
||||
// continuation) lives in model.go.
|
||||
|
||||
type loopMode int
|
||||
|
||||
const (
|
||||
loopModeFixed loopMode = iota // re-fire every fixed interval
|
||||
loopModeSelfPaced // model chooses the next wake each iteration
|
||||
)
|
||||
|
||||
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
|
||||
// or forgotten loop from burning tokens forever.
|
||||
const (
|
||||
loopMinInterval = 30 * time.Second
|
||||
loopMaxInterval = 24 * time.Hour
|
||||
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
|
||||
|
||||
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
|
||||
// loop stops on its own even if it never reaches the iteration cap.
|
||||
loopMaxAge = 24 * time.Hour
|
||||
|
||||
loopSelfPaceMin = 1 * time.Minute
|
||||
loopSelfPaceMax = 1 * time.Hour
|
||||
loopSelfPaceDefault = 15 * time.Minute
|
||||
|
||||
// loopDoomThreshold ends a loop after this many consecutive identical final
|
||||
// answers — a self-paced or interval loop that gets stuck repeating the same
|
||||
// no-progress result should stop rather than churn.
|
||||
loopDoomThreshold = 5
|
||||
|
||||
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
|
||||
// a loop whose iterations keep erroring stops instead of churning to the
|
||||
// iteration cap (a failed turn has no final answer, so the doom guard can't see
|
||||
// the lack of progress).
|
||||
loopMaxFailures = 3
|
||||
|
||||
// loopPollInterval is how often the controller checks whether a loop is due.
|
||||
// A loop only fires while idle, so this is a cheap between-turn poll.
|
||||
loopPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// loopState is one active loop owned by the current session.
|
||||
type loopState struct {
|
||||
id string
|
||||
mode loopMode
|
||||
prompt string // the prompt or "/command" re-run each iteration
|
||||
interval time.Duration
|
||||
iteration int
|
||||
maxIter int // 0 => loopMaxIterations
|
||||
createdAt time.Time
|
||||
nextRunAt time.Time
|
||||
// lastResult / repeatRun drive doom-loop detection: consecutive identical
|
||||
// final answers past loopDoomThreshold end the loop.
|
||||
lastResult string
|
||||
repeatRun int
|
||||
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
|
||||
// stops (a failed turn has no final answer for the doom guard to compare).
|
||||
failRun int
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (l *loopState) iterationCap() int {
|
||||
if l.maxIter > 0 {
|
||||
return l.maxIter
|
||||
}
|
||||
return loopMaxIterations
|
||||
}
|
||||
|
||||
// due reports whether the loop should fire at now (active, not paused, past its
|
||||
// next-run mark).
|
||||
func (l *loopState) due(now time.Time) bool {
|
||||
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
|
||||
}
|
||||
|
||||
// cadenceText renders the loop's cadence for status lines.
|
||||
func (l *loopState) cadenceText() string {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
return "self-paced"
|
||||
}
|
||||
return "every " + formatLoopDuration(l.interval)
|
||||
}
|
||||
|
||||
type loopAction int
|
||||
|
||||
const (
|
||||
loopActionUsage loopAction = iota // show usage / list when empty
|
||||
loopActionStart // start a new loop
|
||||
loopActionStop // stop one by id
|
||||
loopActionStopAll // stop every loop
|
||||
loopActionList // list active loops
|
||||
)
|
||||
|
||||
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
|
||||
// command name).
|
||||
type loopCommand struct {
|
||||
action loopAction
|
||||
targetID string
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
|
||||
// surface back to the user.
|
||||
note string
|
||||
}
|
||||
|
||||
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
// loopControlRe matches a self-paced loop's control line at the start of a line,
|
||||
// tolerant of a leading markdown bullet/quote marker and any trailing note after
|
||||
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
|
||||
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
|
||||
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
|
||||
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
|
||||
|
||||
// parseLoopCommand interprets the arguments to `/loop`. Forms:
|
||||
//
|
||||
// (empty) -> usage/list
|
||||
// list | status -> list active loops
|
||||
// stop | stop all | stop <id>
|
||||
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
|
||||
// <prompt> every <interval>
|
||||
// <prompt> -> self-paced loop (no interval)
|
||||
func parseLoopCommand(args string) loopCommand {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return loopCommand{action: loopActionUsage}
|
||||
}
|
||||
|
||||
first := strings.ToLower(strings.Fields(trimmed)[0])
|
||||
switch first {
|
||||
case "list", "status", "ls":
|
||||
return loopCommand{action: loopActionList}
|
||||
case "stop", "cancel", "kill":
|
||||
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
|
||||
if rest == "" || strings.EqualFold(rest, "all") {
|
||||
if strings.EqualFold(rest, "all") {
|
||||
return loopCommand{action: loopActionStopAll}
|
||||
}
|
||||
// bare "stop" stops all when ambiguous; the caller may special-case a
|
||||
// single active loop.
|
||||
return loopCommand{action: loopActionStop, targetID: ""}
|
||||
}
|
||||
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
|
||||
}
|
||||
|
||||
// Leading interval token: "5m <prompt>".
|
||||
if fields := strings.Fields(trimmed); len(fields) >= 2 {
|
||||
if d, ok := parseLoopInterval(fields[0]); ok {
|
||||
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing "every <interval>".
|
||||
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// No interval anywhere -> self-paced.
|
||||
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
|
||||
}
|
||||
|
||||
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
|
||||
// real duration token so "check every PR" is not mis-parsed as a cadence).
|
||||
func splitTrailingEvery(input string) (string, time.Duration, bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) < 3 {
|
||||
return "", 0, false
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
prev := fields[len(fields)-2]
|
||||
if !strings.EqualFold(prev, "every") {
|
||||
return "", 0, false
|
||||
}
|
||||
d, ok := parseLoopInterval(last)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
|
||||
if prompt == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return prompt, d, true
|
||||
}
|
||||
|
||||
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
|
||||
func parseLoopInterval(token string) (time.Duration, bool) {
|
||||
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
unit := map[string]time.Duration{
|
||||
"s": time.Second,
|
||||
"m": time.Minute,
|
||||
"h": time.Hour,
|
||||
"d": 24 * time.Hour,
|
||||
}[m[2]]
|
||||
return time.Duration(n) * unit, true
|
||||
}
|
||||
|
||||
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
|
||||
// returns an advisory note when it was adjusted.
|
||||
func clampLoopInterval(d time.Duration) (time.Duration, string) {
|
||||
switch {
|
||||
case d < loopMinInterval:
|
||||
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
|
||||
case d > loopMaxInterval:
|
||||
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
|
||||
default:
|
||||
return d, ""
|
||||
}
|
||||
}
|
||||
|
||||
// loopTickMsg is the between-turn poll that fires a due loop while the session is
|
||||
// idle. seq invalidates a tick left over from before the loop set changed.
|
||||
type loopTickMsg struct{ seq int }
|
||||
|
||||
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
|
||||
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
|
||||
cmd := parseLoopCommand(args)
|
||||
switch cmd.action {
|
||||
case loopActionList:
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
case loopActionUsage:
|
||||
if len(m.loops) > 0 {
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
}
|
||||
return m.appendLoopSystem(loopUsageText()), nil
|
||||
case loopActionStop:
|
||||
return m.stopLoop(cmd.targetID)
|
||||
case loopActionStopAll:
|
||||
return m.stopAllLoops()
|
||||
case loopActionStart:
|
||||
return m.startLoop(cmd)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
|
||||
// iteration fires on the next idle tick (nextRunAt = now).
|
||||
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
|
||||
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
|
||||
return m.appendLoopSystem(reason), nil
|
||||
}
|
||||
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
|
||||
// goal through the playbook + LOOP: control line (see firePrompt), which a
|
||||
// custom command's expanded template never carries — so a bare self-paced
|
||||
// /command would silently degrade to adaptive polling with no way to report
|
||||
// done. Require an explicit interval for command loops instead.
|
||||
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
|
||||
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
|
||||
}
|
||||
m.loopCounter++
|
||||
id := "L" + strconv.Itoa(m.loopCounter)
|
||||
loop := &loopState{
|
||||
id: id,
|
||||
mode: cmd.mode,
|
||||
prompt: cmd.prompt,
|
||||
interval: cmd.interval,
|
||||
createdAt: m.now(),
|
||||
nextRunAt: m.now(), // fire the first iteration on the next idle tick
|
||||
}
|
||||
m.loops = append(m.loops, loop)
|
||||
note := ""
|
||||
if cmd.note != "" {
|
||||
note = " (" + cmd.note + ")"
|
||||
}
|
||||
m = m.appendLoopSystem(fmt.Sprintf(
|
||||
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
|
||||
id, loop.cadenceText(), note, id))
|
||||
return m.ensureLoopTick()
|
||||
}
|
||||
|
||||
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
|
||||
func (m model) stopLoop(id string) (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
if id == "" {
|
||||
if len(m.loops) == 1 {
|
||||
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
|
||||
}
|
||||
if m.findLoop(id) == nil {
|
||||
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
|
||||
}
|
||||
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
|
||||
// keep firing (ensureLoopTick no-ops when this was the last loop).
|
||||
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
|
||||
func (m model) stopAllLoops() (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
n := len(m.loops)
|
||||
m.loops = nil
|
||||
m.loopSeq++ // invalidate the pending poll tick
|
||||
m.loopTicking = false
|
||||
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
|
||||
}
|
||||
|
||||
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
|
||||
// from the poll tick; a no-op while a turn, modal, or queued user message is
|
||||
// pending (the loop simply waits for the next idle tick).
|
||||
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
|
||||
if m.loopBusy() || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
now := m.now()
|
||||
var due *loopState
|
||||
for _, l := range m.loops {
|
||||
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
|
||||
due = l
|
||||
}
|
||||
}
|
||||
if due == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.activeLoopID = due.id
|
||||
due.nextRunAt = time.Time{} // mark running
|
||||
return m.fireLoopPrompt(due.firePrompt())
|
||||
}
|
||||
|
||||
// firePrompt is the text actually sent for this iteration. A self-paced plain
|
||||
// prompt is wrapped in the maintenance playbook so the model knows to do one
|
||||
// increment of work and end with a LOOP: control line; a /command or a
|
||||
// fixed-interval prompt is sent verbatim.
|
||||
func (l *loopState) firePrompt() string {
|
||||
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
|
||||
return loopSelfPacePlaybook(l)
|
||||
}
|
||||
return l.prompt
|
||||
}
|
||||
|
||||
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
|
||||
// to one, otherwise a plain prompt.
|
||||
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
|
||||
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
|
||||
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
|
||||
return next, teaCmd
|
||||
}
|
||||
}
|
||||
return m.launchPrompt(prompt)
|
||||
}
|
||||
|
||||
// advanceLoop updates a loop after one of its iterations completes: it folds the
|
||||
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
|
||||
// control line, enforces the iteration and age caps, then schedules the next wake
|
||||
// (or stops the loop).
|
||||
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
|
||||
l := m.findLoop(id)
|
||||
if l == nil {
|
||||
return m // stopped mid-run
|
||||
}
|
||||
l.iteration++
|
||||
if runErr != nil {
|
||||
l.failRun++
|
||||
} else {
|
||||
l.failRun = 0
|
||||
}
|
||||
res := strings.TrimSpace(finalAnswer)
|
||||
if res != "" && res == l.lastResult {
|
||||
l.repeatRun++
|
||||
} else {
|
||||
l.repeatRun = 0
|
||||
}
|
||||
l.lastResult = res
|
||||
|
||||
// Only self-paced iterations run the playbook, so only they can carry a control
|
||||
// line; a failed turn has no trustworthy final answer to read one from.
|
||||
done, wake := false, time.Duration(0)
|
||||
if l.mode == loopModeSelfPaced && runErr == nil {
|
||||
done, wake = parseLoopControl(finalAnswer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case done:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
|
||||
case l.failRun >= loopMaxFailures:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
|
||||
case l.iteration >= l.iterationCap():
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
|
||||
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
|
||||
case l.repeatRun >= loopDoomThreshold:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
|
||||
}
|
||||
|
||||
if l.mode == loopModeSelfPaced {
|
||||
// A model-chosen next wake (from the control line) wins; otherwise fall back to
|
||||
// an adaptive cadence that checks back often while work is fresh and settles to
|
||||
// a slow heartbeat as iterations accrue.
|
||||
delay := wake
|
||||
if delay <= 0 {
|
||||
delay = adaptiveSelfPaceDelay(l.iteration)
|
||||
}
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLoopControl reads a self-paced loop's control signal from the model's final
|
||||
// answer. The last control line wins (the model may reference the protocol before
|
||||
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
|
||||
func parseLoopControl(answer string) (done bool, wake time.Duration) {
|
||||
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
|
||||
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
|
||||
norm := strings.ReplaceAll(answer, "\r\n", "\n")
|
||||
norm = strings.ReplaceAll(norm, "\r", "\n")
|
||||
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
|
||||
if len(matches) == 0 {
|
||||
return false, 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
if strings.EqualFold(last[1], "done") {
|
||||
return true, 0
|
||||
}
|
||||
if last[2] != "" {
|
||||
if d, ok := parseLoopInterval(last[2]); ok {
|
||||
return false, d
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
|
||||
// instructions the model follows each iteration: do one real increment of work,
|
||||
// then emit a single control line so the harness knows whether to stop or when to
|
||||
// wake next — the self-paced maintenance playbook the feature ships with.
|
||||
func loopSelfPacePlaybook(l *loopState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
|
||||
b.WriteString(strconv.Itoa(l.iteration + 1))
|
||||
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
|
||||
b.WriteString("Goal: ")
|
||||
b.WriteString(strings.TrimSpace(l.prompt))
|
||||
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
|
||||
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
|
||||
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
|
||||
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
|
||||
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
|
||||
// early (work is actively progressing), slowing to a heartbeat as it matures.
|
||||
func adaptiveSelfPaceDelay(iteration int) time.Duration {
|
||||
switch {
|
||||
case iteration < 2:
|
||||
return 2 * time.Minute
|
||||
case iteration < 5:
|
||||
return 5 * time.Minute
|
||||
case iteration < 10:
|
||||
return 15 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) findLoop(id string) *loopState {
|
||||
for _, l := range m.loops {
|
||||
if l.id == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) removeLoop(id, note string) model {
|
||||
kept := make([]*loopState, 0, len(m.loops))
|
||||
for _, l := range m.loops {
|
||||
if l.id != id {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
m.loops = kept
|
||||
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
|
||||
m.loopTicking = false
|
||||
if note != "" {
|
||||
m = m.appendLoopSystem(note)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ensureLoopTick starts the idle poll ticker if it is not already running.
|
||||
func (m model) ensureLoopTick() (model, tea.Cmd) {
|
||||
if m.loopTicking || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.loopTicking = true
|
||||
return m, m.scheduleLoopTick()
|
||||
}
|
||||
|
||||
func (m model) scheduleLoopTick() tea.Cmd {
|
||||
seq := m.loopSeq
|
||||
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
|
||||
return loopTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
|
||||
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
|
||||
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
|
||||
// The modal check is load-bearing: a loop must not launch a run behind an open
|
||||
// /resume picker, or that run would complete into whatever session the user then
|
||||
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
|
||||
// collide.
|
||||
func (m model) loopBusy() bool {
|
||||
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
|
||||
}
|
||||
|
||||
func (m model) appendLoopSystem(text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m
|
||||
}
|
||||
|
||||
// loopActive reports whether any loop is running (for the footer + leave warning).
|
||||
func (m model) loopActive() bool { return len(m.loops) > 0 }
|
||||
|
||||
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
|
||||
// or "" when no loops are active.
|
||||
func (m model) loopFooterSummary() string {
|
||||
if len(m.loops) == 0 {
|
||||
return ""
|
||||
}
|
||||
var next time.Time
|
||||
for _, l := range m.loops {
|
||||
if l.nextRunAt.IsZero() || l.paused {
|
||||
continue
|
||||
}
|
||||
if next.IsZero() || l.nextRunAt.Before(next) {
|
||||
next = l.nextRunAt
|
||||
}
|
||||
}
|
||||
label := fmt.Sprintf("%d loop", len(m.loops))
|
||||
if len(m.loops) != 1 {
|
||||
label += "s"
|
||||
}
|
||||
if next.IsZero() {
|
||||
return label
|
||||
}
|
||||
return label + " · next " + next.Format("3:04pm")
|
||||
}
|
||||
|
||||
func (m model) loopListText() string {
|
||||
if len(m.loops) == 0 {
|
||||
return "No active loops."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Active loops:\n")
|
||||
now := m.now()
|
||||
for _, l := range m.loops {
|
||||
when := "running"
|
||||
if !l.nextRunAt.IsZero() {
|
||||
if d := l.nextRunAt.Sub(now); d > 0 {
|
||||
when = "next in " + formatLoopDuration(d.Round(time.Second))
|
||||
} else {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loopUsageText() string {
|
||||
return "Repeat a prompt or command:\n" +
|
||||
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
|
||||
" /loop watch CI every 2m — trailing interval form\n" +
|
||||
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
|
||||
" /loop list — show active loops\n" +
|
||||
" /loop stop [id|all] — stop a loop\n" +
|
||||
"Loops run while this session is idle and stop when you close it."
|
||||
}
|
||||
|
||||
// validateLoopTarget rejects looping a built-in command or an unresolved custom
|
||||
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
|
||||
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
|
||||
// being scheduled and then re-sent as literal text on every tick.
|
||||
func (m model) validateLoopTarget(prompt string) (string, bool) {
|
||||
p := strings.TrimSpace(prompt)
|
||||
if p == "" {
|
||||
return "Nothing to loop — give a prompt or a /command.", false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
switch parseCommand(p).kind {
|
||||
case commandPrompt:
|
||||
// A bare "/" or similar that isn't a command — treat as a plain prompt.
|
||||
case commandUnknown:
|
||||
name, _ := splitUserCommand(p)
|
||||
if _, ok := m.lookupUserCommand(name); !ok {
|
||||
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
|
||||
}
|
||||
default:
|
||||
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
|
||||
// the session that created them; carrying them across /new or /resume would fire the
|
||||
// old session's prompt into an unrelated conversation. Returns the count cleared so
|
||||
// the caller can note it. Pure state reset — no transcript writes.
|
||||
func (m model) clearLoopsForSessionSwitch() (model, int) {
|
||||
n := len(m.loops)
|
||||
if n == 0 {
|
||||
return m, 0
|
||||
}
|
||||
m.loops = nil
|
||||
m.activeLoopID = ""
|
||||
m.loopTicking = false
|
||||
m.loopSeq++ // invalidate any pending poll tick
|
||||
return m, n
|
||||
}
|
||||
|
||||
func truncateLoopPrompt(prompt string) string {
|
||||
const max = 48
|
||||
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
|
||||
runes := []rune(p)
|
||||
if len(runes) <= max {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
|
||||
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
|
||||
// band, defaulting an unset/invalid value.
|
||||
func clampSelfPaceDelay(d time.Duration) time.Duration {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return loopSelfPaceDefault
|
||||
case d < loopSelfPaceMin:
|
||||
return loopSelfPaceMin
|
||||
case d > loopSelfPaceMax:
|
||||
return loopSelfPaceMax
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
|
||||
// text, preferring the largest whole unit.
|
||||
func formatLoopDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
case d%time.Hour == 0 && d >= time.Hour:
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
case d%time.Minute == 0 && d >= time.Minute:
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
default:
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
)
|
||||
|
||||
func loopTestModel(t *testing.T, now time.Time) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.now = func() time.Time { return now }
|
||||
return m
|
||||
}
|
||||
|
||||
func startFixedLoop(m model, prompt string, interval time.Duration) model {
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStartLoopRegistersAndSchedules(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "check the build", 5*time.Minute)
|
||||
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("want 1 loop, got %d", len(m.loops))
|
||||
}
|
||||
l := m.loops[0]
|
||||
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
|
||||
t.Fatalf("unexpected loop %+v", l)
|
||||
}
|
||||
// First iteration is due immediately (nextRunAt == now).
|
||||
if !l.due(now) {
|
||||
t.Errorf("first iteration should be due at creation")
|
||||
}
|
||||
if !m.loopTicking {
|
||||
t.Errorf("starting a loop should start the poll ticker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", 5*time.Minute)
|
||||
id := m.loops[0].id
|
||||
|
||||
m = m.advanceLoop(id, "did some work", nil)
|
||||
l := m.loops[0]
|
||||
if l.iteration != 1 {
|
||||
t.Fatalf("iteration = %d, want 1", l.iteration)
|
||||
}
|
||||
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedUsesModelDelay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
|
||||
id := m.loops[0].id
|
||||
// The model carries its chosen next wake in the reply's control line.
|
||||
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
|
||||
if len(m.loops) == 0 {
|
||||
t.Fatal("a CONTINUE control line should keep the loop running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
|
||||
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoneStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedLoopIgnoresControlLine(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "watch CI", time.Minute)
|
||||
id := m.loops[0].id
|
||||
// A fixed-interval loop never runs the playbook, so a stray control line in the
|
||||
// output is not a completion signal — it keeps its wall-clock cadence.
|
||||
m = m.advanceLoop(id, "LOOP: DONE", nil)
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
|
||||
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopExpiresByAge(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "forgotten", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
|
||||
m = m.advanceLoop(id, "still going", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a loop past its age cap should auto-expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoomGuardStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "spin", time.Minute)
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "identical answer", nil)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("doom guard should stop a loop repeating identical results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopIterationCapStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "bounded", time.Minute)
|
||||
m.loops[0].maxIter = 3
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < 3 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop at its iteration cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopLoopByID(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m, cmd := m.stopLoop(id)
|
||||
if len(m.loops) != 1 || m.findLoop(id) != nil {
|
||||
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
|
||||
}
|
||||
// Stopping one of several loops must keep the poll ticker alive for the rest:
|
||||
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
|
||||
if cmd == nil || !m.loopTicking {
|
||||
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
|
||||
}
|
||||
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
|
||||
// leaves the target empty and stopLoop special-cases the one-loop case).
|
||||
soleID := m.loops[0].id
|
||||
m, _ = m.stopLoop("")
|
||||
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
|
||||
t.Fatalf("bare stop should remove the sole active loop")
|
||||
}
|
||||
m = startFixedLoop(m, "c", time.Minute)
|
||||
m = startFixedLoop(m, "d", time.Minute)
|
||||
m, _ = m.stopAllLoops()
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("stopAllLoops should clear all loops")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.pending = true // a turn is in flight
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatalf("a due loop must not fire while a turn is pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", time.Minute) // due immediately
|
||||
m.picker = &commandPicker{} // e.g. an open /resume session picker
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
|
||||
}
|
||||
if got.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
|
||||
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
|
||||
t.Error("early iterations should check back sooner than later ones")
|
||||
}
|
||||
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
|
||||
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFooterSummary(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if m.loopFooterSummary() != "" {
|
||||
t.Error("no loops -> empty footer summary")
|
||||
}
|
||||
m = startFixedLoop(m, "a", 5*time.Minute)
|
||||
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
|
||||
got := m.loopFooterSummary()
|
||||
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
|
||||
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
|
||||
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCommandThroughSubmit(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.input.SetValue("/loop 5m tidy the imports")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.loops) != 1 {
|
||||
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
|
||||
}
|
||||
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
|
||||
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
|
||||
}
|
||||
if !next.loopTicking {
|
||||
t.Fatalf("submitting /loop should start the poll ticker")
|
||||
}
|
||||
|
||||
// /loop stop all through the same path clears it.
|
||||
next.input.SetValue("/loop stop all")
|
||||
updated2, _ := next.Update(testKey(tea.KeyEnter))
|
||||
final := updated2.(model)
|
||||
if len(final.loops) != 0 {
|
||||
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleLoopTickIgnored(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.loopSeq = 7
|
||||
// A tick from before the seq changed must be dropped and stop the ticker.
|
||||
updated, cmd := m.Update(loopTickMsg{seq: 3})
|
||||
next := updated.(model)
|
||||
if next.loopTicking {
|
||||
t.Error("a stale loop tick should stop the ticker")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("a stale loop tick should not reschedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "flaky", time.Minute)
|
||||
id := m.loops[0].id
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "", boom)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "recovers", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("a recovered failure streak should not stop the loop")
|
||||
}
|
||||
if m.loops[0].failRun != 0 {
|
||||
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", 5*time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.runCancel = func() {}
|
||||
|
||||
m.cancelRun()
|
||||
|
||||
if m.activeLoopID != "" {
|
||||
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
|
||||
}
|
||||
if m.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if _, ok := m.validateLoopTarget("/model list"); ok {
|
||||
t.Error("looping a built-in command should be rejected")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
|
||||
t.Error("a plain prompt should be a valid loop target")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget(""); ok {
|
||||
t.Error("an empty target should be rejected")
|
||||
}
|
||||
// A custom command that does not exist is rejected up front, so it is never
|
||||
// scheduled and then re-sent as literal "/…" text on every tick.
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
|
||||
t.Error("an unresolved custom command should be rejected")
|
||||
}
|
||||
// A registered custom command is accepted.
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
|
||||
t.Error("an existing custom command should be a valid loop target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedCommandLoopRejected(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
// A /command with no interval parses as self-paced, but self-paced mode drives
|
||||
// a free-form goal via the playbook + LOOP: protocol, which a command's
|
||||
// expanded template never carries — so a bare self-paced /command is rejected.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
|
||||
}
|
||||
// The same command with an interval (fixed) is accepted.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval /command loop should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLoopsForSessionSwitch(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m, n := m.clearLoopsForSessionSwitch()
|
||||
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
|
||||
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
|
||||
n, len(m.loops), m.activeLoopID, m.loopTicking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNewSessionStopsLoops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m = m.startNewSession()
|
||||
if len(m.loops) != 0 || m.loopTicking {
|
||||
t.Fatal("/new should stop loops tied to the previous session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopClearRequiresConfirm(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// First /clear with a loop active arms the confirm and does not clear yet.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear with active loops should arm the confirm, not clear")
|
||||
}
|
||||
if len(n1.loops) != 1 {
|
||||
t.Fatal("the loop should still be active after the warning")
|
||||
}
|
||||
|
||||
// A different command disarms the confirm.
|
||||
n1.input.SetValue("/loop list")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("an intervening command should disarm the leave confirm")
|
||||
}
|
||||
|
||||
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
|
||||
n2.input.SetValue("/clear")
|
||||
u3, _ := n2.Update(testKey(tea.KeyEnter))
|
||||
n3 := u3.(model)
|
||||
n3.input.SetValue("/clear")
|
||||
u4, _ := n3.Update(testKey(tea.KeyEnter))
|
||||
n4 := u4.(model)
|
||||
if n4.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a second consecutive /clear should confirm and disarm")
|
||||
}
|
||||
if len(n4.loops) != 1 {
|
||||
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
|
||||
}
|
||||
// The confirmed clear must actually wipe the transcript, not silently no-op:
|
||||
// only the clear path appends the "Transcript cleared" note, and the pre-clear
|
||||
// loop-start row must be gone.
|
||||
clearedNote, staleRow := false, false
|
||||
for _, r := range n4.transcript {
|
||||
if strings.Contains(r.text, "Transcript cleared") {
|
||||
clearedNote = true
|
||||
}
|
||||
if strings.Contains(r.text, "Loop L1 started") {
|
||||
staleRow = true
|
||||
}
|
||||
}
|
||||
if !clearedNote || staleRow {
|
||||
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// Arm the /clear confirm.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear should arm the confirm")
|
||||
}
|
||||
|
||||
// A prompt submitted while a run streams is queued via an early return in
|
||||
// handleSubmit; it must still disarm the confirm (the disarm runs before those
|
||||
// returns). Otherwise a later /clear would be treated as the second press.
|
||||
n1.pending = true
|
||||
n1.input.SetValue("a follow-up question")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLoopCommandModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
action loopAction
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
}{
|
||||
{"", loopActionUsage, 0, "", 0},
|
||||
{"list", loopActionList, 0, "", 0},
|
||||
{"status", loopActionList, 0, "", 0},
|
||||
{"stop all", loopActionStopAll, 0, "", 0},
|
||||
{"stop ab12", loopActionStop, 0, "", 0},
|
||||
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
|
||||
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
|
||||
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
|
||||
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
|
||||
// "every PR" is not a cadence — the token after "every" isn't a duration.
|
||||
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseLoopCommand(c.in)
|
||||
if got.action != c.action {
|
||||
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
|
||||
}
|
||||
if c.action == loopActionStart {
|
||||
if got.mode != c.mode {
|
||||
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
|
||||
}
|
||||
if got.prompt != c.prompt {
|
||||
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
|
||||
}
|
||||
if got.interval != c.interval {
|
||||
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopCommandStopTarget(t *testing.T) {
|
||||
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
|
||||
t.Fatalf("bare stop = %+v, want stop with empty target", got)
|
||||
}
|
||||
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
|
||||
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLoopInterval(t *testing.T) {
|
||||
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
|
||||
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
|
||||
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
|
||||
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSelfPaceDelay(t *testing.T) {
|
||||
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
|
||||
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
|
||||
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
|
||||
}
|
||||
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
|
||||
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
|
||||
t.Errorf("in-band = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLoopDuration(t *testing.T) {
|
||||
cases := map[time.Duration]string{
|
||||
30 * time.Second: "30s",
|
||||
90 * time.Second: "90s",
|
||||
5 * time.Minute: "5m",
|
||||
2 * time.Hour: "2h",
|
||||
24 * time.Hour: "1d",
|
||||
}
|
||||
for d, want := range cases {
|
||||
if got := formatLoopDuration(d); got != want {
|
||||
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopControl(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
done bool
|
||||
wake time.Duration
|
||||
}{
|
||||
{"did the work\nLOOP: DONE", true, 0},
|
||||
{"progress\nLOOP: CONTINUE", false, 0},
|
||||
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
|
||||
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
|
||||
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
|
||||
{"no control line here", false, 0}, // absent -> adaptive
|
||||
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
|
||||
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
|
||||
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
|
||||
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
|
||||
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
|
||||
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
|
||||
}
|
||||
for _, c := range cases {
|
||||
done, wake := parseLoopControl(c.in)
|
||||
if done != c.done || wake != c.wake {
|
||||
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
|
||||
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
|
||||
got := self.firePrompt()
|
||||
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
|
||||
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
|
||||
}
|
||||
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
|
||||
if fixed.firePrompt() != "check the build" {
|
||||
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
|
||||
}
|
||||
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
|
||||
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
|
||||
if cmd.firePrompt() != "/babysit" {
|
||||
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStateDue(t *testing.T) {
|
||||
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
l := &loopState{nextRunAt: base}
|
||||
if l.due(base.Add(-time.Second)) {
|
||||
t.Error("loop should not be due before nextRunAt")
|
||||
}
|
||||
if !l.due(base) {
|
||||
t.Error("loop should be due at nextRunAt")
|
||||
}
|
||||
l.paused = true
|
||||
if l.due(base.Add(time.Hour)) {
|
||||
t.Error("paused loop should never be due")
|
||||
}
|
||||
}
|
||||
+91
-5
@@ -62,6 +62,7 @@ const dragEdgeScrollStep = 1
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
appVersion string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
@@ -190,10 +191,22 @@ type model struct {
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
// pending poll tick when loops are stopped; loopTicking guards against scheduling
|
||||
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
|
||||
// /quit while loops are active (see handleSubmit).
|
||||
loops []*loopState
|
||||
activeLoopID string
|
||||
loopSeq int
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -735,6 +748,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
appVersion: strings.TrimSpace(options.Version),
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
@@ -1896,6 +1910,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
case loopTickMsg:
|
||||
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
|
||||
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
|
||||
if msg.seq != m.loopSeq || len(m.loops) == 0 {
|
||||
m.loopTicking = false
|
||||
return m, nil
|
||||
}
|
||||
var fireCmd tea.Cmd
|
||||
m, fireCmd = m.fireDueLoopIfIdle()
|
||||
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
// A run cancelled while in flight still finishes in its goroutine and
|
||||
@@ -2065,8 +2089,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
var loopTickCmd tea.Cmd
|
||||
if loopID := m.activeLoopID; loopID != "" {
|
||||
m.activeLoopID = ""
|
||||
loopFinalAnswer := ""
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
loopFinalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
|
||||
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
|
||||
// other loops remain.
|
||||
m, loopTickCmd = m.ensureLoopTick()
|
||||
}
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -3789,6 +3830,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
command := parseCommand(input)
|
||||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||||
// repeat of that same command only; any other submission — including one queued
|
||||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||||
@@ -3822,6 +3870,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
// A foreground loop keeps firing after /clear (it wipes the screen, not the
|
||||
// session), so warn once before clearing the context it will run into.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandClear {
|
||||
m.loopLeavePrompt = commandClear
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
@@ -3830,6 +3886,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
// /clear wipes the transcript but not the session, so loops keep running;
|
||||
// say so rather than let them silently fire into a "cleared" screen.
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
@@ -3840,7 +3901,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||||
m.loopLeavePrompt = commandExit
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
// immediately would orphan the blobs and break /rewind.
|
||||
@@ -4333,6 +4404,21 @@ func (m *model) cancelRun() {
|
||||
m.runCancel()
|
||||
}
|
||||
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
|
||||
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
|
||||
// late message is drained through flushRunIDs, not advanceLoop), so clear the
|
||||
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
|
||||
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
|
||||
// unrelated turn would be misattributed as this loop's completion.
|
||||
if m.activeLoopID != "" {
|
||||
if l := m.findLoop(m.activeLoopID); l != nil {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
}
|
||||
m.activeLoopID = ""
|
||||
}
|
||||
// Remember the in-flight run — and the session it was recording into — so
|
||||
// its final agentResponseMsg is still drained for session-event persistence
|
||||
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
Version string // CLI build version, shown on the home screen; empty hides it
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
|
||||
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
// Loops belong to the previous session; stop them so they don't fire the old
|
||||
// conversation's prompt into the fresh one.
|
||||
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
m = updated
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -205,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
return m, "Sessions\nerror: " + err.Error()
|
||||
}
|
||||
|
||||
// Capture the current session id before switching so loops are only torn down
|
||||
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
@@ -213,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
if m.modelName == "" {
|
||||
m.modelName = session.ModelID
|
||||
}
|
||||
loopsCleared := 0
|
||||
if session.SessionID != previousID {
|
||||
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
||||
}
|
||||
|
||||
rows := initialTranscript()
|
||||
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
||||
if loopsCleared > 0 {
|
||||
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
||||
}
|
||||
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
||||
m.transcript = rows
|
||||
// Every rehydrated row is settled by construction, so resetting the flush
|
||||
|
||||
+21
-3
@@ -92,10 +92,14 @@ func (m model) emptyStateLines(width int) []string {
|
||||
// emptyStateExamples seeds the first prompt with a few representative asks.
|
||||
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
|
||||
|
||||
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
|
||||
// screen, omitting any piece that's unknown. Empty when nothing is known.
|
||||
// emptyStateOrientation renders a faint "version · cwd · branch · model" line
|
||||
// for the home screen, omitting any piece that's unknown. Empty when nothing
|
||||
// is known.
|
||||
func (m model) emptyStateOrientation() string {
|
||||
parts := make([]string, 0, 3)
|
||||
parts := make([]string, 0, 4)
|
||||
if version := displayVersion(m.appVersion); version != "" {
|
||||
parts = append(parts, version)
|
||||
}
|
||||
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
|
||||
parts = append(parts, shortenPath(cwd))
|
||||
}
|
||||
@@ -111,6 +115,20 @@ func (m model) emptyStateOrientation() string {
|
||||
return zeroTheme.faint.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
// displayVersion formats the CLI build version for display: numeric releases
|
||||
// gain a "v" prefix (0.2.0 → v0.2.0), anything else (dev, git SHAs) passes
|
||||
// through unchanged. Empty stays empty so unset builds show nothing.
|
||||
func displayVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
if version[0] >= '0' && version[0] <= '9' {
|
||||
return "v" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func zeroWordmarkLines() []string {
|
||||
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
|
||||
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
|
||||
|
||||
@@ -21,6 +21,29 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) {
|
||||
assertNotContains(t, view, "fix the failing test in internal/tools")
|
||||
}
|
||||
|
||||
func TestEmptyStateShowsVersion(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Version: "0.2.0"})
|
||||
m.width, m.height = 100, 30
|
||||
|
||||
view := plainRender(t, m.View())
|
||||
assertContains(t, view, "v0.2.0")
|
||||
}
|
||||
|
||||
func TestDisplayVersion(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"0.2.0", "v0.2.0"},
|
||||
{"v0.2.0", "v0.2.0"},
|
||||
{"dev", "dev"},
|
||||
{" ", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := displayVersion(tc.in); got != tc.want {
|
||||
t.Fatalf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateDisappearsAfterFirstRow(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
|
||||
@@ -218,6 +218,13 @@ func (m model) statusLine(width int) string {
|
||||
} else if summary := m.backgroundTerminalSummary(); summary != "" {
|
||||
left += separator + zeroTheme.muted.Render(summary)
|
||||
}
|
||||
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
|
||||
// running loop is always visible (hidden during an exit/cancel confirm above).
|
||||
if !m.exitConfirmActive && !m.cancelConfirmActive {
|
||||
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
|
||||
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
|
||||
}
|
||||
}
|
||||
|
||||
rightGroups := []string{}
|
||||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user