diff --git a/README.md b/README.md index b75c4851..86ce5d13 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 0890a6b4..e9866511 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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] | /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", diff --git a/internal/tui/loop.go b/internal/tui/loop.go new file mode 100644 index 00000000..0d0ea739 --- /dev/null +++ b/internal/tui/loop.go @@ -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 ` re-fires on a wall-clock cadence. +// - Self-paced — bare `/loop ` 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 +// -> fixed-interval loop (e.g. "5m /babysit") +// every +// -> 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 ". + 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 ". + 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 " every " (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 "" 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 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 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/.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" + } +} diff --git a/internal/tui/loop_controller_test.go b/internal/tui/loop_controller_test.go new file mode 100644 index 00000000..500f97a6 --- /dev/null +++ b/internal/tui/loop_controller_test.go @@ -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") + } +} diff --git a/internal/tui/loop_test.go b/internal/tui/loop_test.go new file mode 100644 index 00000000..cd4c5f91 --- /dev/null +++ b/internal/tui/loop_test.go @@ -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") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index a60ecb73..c994ab5d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -190,10 +190,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 @@ -1896,6 +1908,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 +2087,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 +3828,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 +3868,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 +3884,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 +3899,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 +4402,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 diff --git a/internal/tui/session.go b/internal/tui/session.go index 080e9f1c..c5bd6266 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -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 ` 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 diff --git a/internal/tui/view.go b/internal/tui/view.go index b1a7e557..0057e5f4 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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