feat(tui): /loop — repeat a prompt or command on an interval or self-paced#502
feat(tui): /loop — repeat a prompt or command on an interval or self-paced#502Vasanthdev2004 wants to merge 8 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (7)
WalkthroughAdds a session-scoped Changes/loop command implementation
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
internal/tui/loop_controller_test.go (2)
147-159: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMinor: brittle fixed-length slicing in footer assertion.
got[:6]relies on the label always being ≥6 chars; safe today given"%d loop"/"%d loops"formatting, butstrings.HasPrefix(got, "1 loop")would be more robust against future footer text changes without risking an index-out-of-range panic.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/loop_controller_test.go` around lines 147 - 159, The footer summary test in TestLoopFooterSummary uses brittle fixed-length slicing on got, which can panic and is less resilient to wording changes. Update the assertion to check the prefix of loopFooterSummary using the existing test setup around loopTestModel and startFixedLoop, so it verifies the summary starts with the expected loop count without indexing into the string.
111-125: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMissing coverage for bare
/loop stoptargeting the sole active loop.
parseLoopCommand's comment (ininternal/tui/loop.go) notes bare "stop" is ambiguous and "the caller may special-case a single active loop," but this test only coversstopLoop(id)with an explicit id andstopAllLoops(). Consider adding a case with a single active loop andstopLoop("")to lock in that special-casing.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/loop_controller_test.go` around lines 111 - 125, Add coverage in TestStopLoopByID for the bare stop path handled by parseLoopCommand/stopLoop: create a single active loop, call stopLoop("") on the loop controller model, and assert it removes that sole loop rather than treating it as an invalid or no-op command. Keep the existing explicit-id and stopAllLoops checks, but extend the test to lock in the single-active-loop special case referenced by stopLoop and parseLoopCommand.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/tui/loop.go`:
- Around line 339-375: The advanceLoop method in model is ignoring runErr, so
failing loops keep being re-scheduled until the iteration cap. Update
advanceLoop to incorporate the error state into the stop/backoff decision path:
either stop immediately on repeated failures or count consecutive errors toward
the same no-progress guard used with lastResult/repeatRun. Make sure the fix is
applied in the advanceLoop logic before nextRunAt is set, and use the existing
loop state fields on the loop model to track failure streaks if needed.
In `@internal/tui/model.go`:
- Around line 1912-1928: Clear the loop state when a run is cancelled:
`cancelRun()` currently resets `activeRunID` but leaves `activeLoopID` set,
which lets a cancelled loop iteration bypass `advanceLoop` and corrupt later
turns. Update the cancel/flush path in `internal/tui/model.go` so the
interrupted loop is explicitly cleared or advanced using the loop identifier,
and ensure any loop bookkeeping in `advanceLoop`, `removeLoop`, and the
`activeLoopID` handling stays in sync.
---
Nitpick comments:
In `@internal/tui/loop_controller_test.go`:
- Around line 147-159: The footer summary test in TestLoopFooterSummary uses
brittle fixed-length slicing on got, which can panic and is less resilient to
wording changes. Update the assertion to check the prefix of loopFooterSummary
using the existing test setup around loopTestModel and startFixedLoop, so it
verifies the summary starts with the expected loop count without indexing into
the string.
- Around line 111-125: Add coverage in TestStopLoopByID for the bare stop path
handled by parseLoopCommand/stopLoop: create a single active loop, call
stopLoop("") on the loop controller model, and assert it removes that sole loop
rather than treating it as an invalid or no-op command. Keep the existing
explicit-id and stopAllLoops checks, but extend the test to lock in the
single-active-loop special case referenced by stopLoop and parseLoopCommand.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b6ea9e6b-29f5-4aa9-8e0e-24453227d3f8
📒 Files selected for processing (7)
README.mdinternal/tui/commands.gointernal/tui/loop.gointernal/tui/loop_controller_test.gointernal/tui/loop_test.gointernal/tui/model.gointernal/tui/view.go
Addresses CodeRabbit review on #502: - advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and stops the loop, so a loop whose turns keep erroring no longer churns to the iteration cap (the doom guard only sees identical non-empty answers). - cancelRun clears activeLoopID and re-arms the interrupted loop for its next cadence, so a cancelled iteration is not left running forever and the next unrelated turn is not misattributed as that loop's completion.
6d1b487 to
504f7be
Compare
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
jatmn
left a comment
There was a problem hiding this comment.
I found a couple of issues that need to be addressed before this is ready.
Findings
-
[P2] Stop carrying loops across session switches
internal/tui/session.go:51
Loops are described as session-scoped, butstartNewSession()clears the session/transcript state without clearingm.loopsor invalidating the loop ticker. The same pattern exists on other session replacement paths like/resume, so an active loop from the previous conversation can keep firing after the user starts or resumes an unrelated session, injecting the old loop prompt into the new context. Please either stop active loops when the TUI switches/rebuilds sessions or persist and reattach them to their owning session instead of leaving them as global model state. -
[P2] Wire the self-paced control path before advertising it
internal/tui/loop.go:378
The self-paced path still depends onm.loopNextWakeandm.loopDone, but there is no runtime writer for either field: the only production writes reset them before a loop fires, and the only non-reset writes are tests. That means a bare/loop keep tidying docscannot actually let the model choose its next wake or report done as issue #501 and the in-app usage text describe; it can only follow the hard-coded adaptive delay until it hits repeated-output/failure/iteration guards. Please either add the promised loop-control channel/tool and playbook wiring, or narrow the command/docs/PR scope so self-paced mode is honestly described as adaptive polling without model-controlled completion. -
[P2] Reject missing custom slash commands instead of looping them as prompts
internal/tui/loop.go:337
validateLoopTarget()accepts any slash-prefixedcommandUnknownvalue so custom commands can be scheduled, butfireLoopPrompt()falls back tolaunchPrompt(prompt)whenhandleUserCommand()cannot resolve the command. As a result,/loop 5m /babysit-prssucceeds even if that custom command file does not exist, and every tick sends the literal text/babysit-prsto the model instead of following the normal unknown-command error path. Please resolve custom slash commands when creating the loop, or stop/report the loop on the first unresolved command, so only real custom commands are scheduled. -
[P2] Add the active-loop leave gate before
/clearand/quit
internal/tui/model.go:3685
The linked issue calls for a leave-warning gate before/quitand/clearwhen loops are active, but the current handlers do not gate either path:/clearwipes the transcript first and only then appends a note that loops are still running, while/exit//quitgoes straight toquit()when idle without checkingloopActive(). For a foreground token-spending loop, the user needs a chance to stop or confirm before clearing the visible context it will continue from or closing the session. Please add the pre-action warning/confirmation gate for active loops on both paths.
|
Thanks, these were all fair. Pushed 47c799e. 1. Session scope — loops now stop on 2. Self-paced control path — made it real instead of narrowing the docs. Each self-paced iteration now runs a maintenance playbook, and the model ends its reply with a control line: 3. Custom commands — 4. Leave gate — Also folded in the two remaining scope items the pre-merge check wanted: the One extra note since it touches what you flagged: I ran a review pass over the above and caught a few edge cases in the new code — the control-line regex missed CRLF replies and a |
jatmn
left a comment
There was a problem hiding this comment.
Thanks for the update. I rechecked the changed paths and found an issue that still needs to be addressed.
Findings
-
[P2] Keep the remaining loops scheduled after stopping one loop
internal/tui/loop.go:312
stopLoop(id)removes the requested loop by returningremoveLoop(...), butremoveLoopalways invalidates the current poll tick and setsloopTicking=false. Unlike theadvanceLoopcompletion path, the manual/loop stop <id>path never callsensureLoopTick()afterward, so if a user has multiple loops and stops only one of them, the remaining loops stay inm.loopsand still appear in/loop list/the footer, but no poll tick is scheduled to fire them anymore. Please restart the loop ticker whenever a manual single-loop stop leaves other loops active, and add coverage for that multi-loop stop case. -
[P2] Apply the self-paced control protocol to custom command loops
internal/tui/loop.go:352
Bare/loop /some-commandis accepted as a self-paced loop because the command usage advertises<prompt|/command>andparseLoopCommandmakes every no-interval target self-paced. HoweverfirePrompt()only wraps self-paced non-slash prompts inloopSelfPacePlaybook; slash targets are sent verbatim tohandleUserCommand(), which expands the custom command template and launches it normally. Unless every custom command author already included the hiddenLOOP: DONE/LOOP: CONTINUEprotocol in their template, the model is never told how to stop the self-paced command or choose its next wake, so the advertised self-paced behavior degrades to adaptive polling until another safety guard fires. Please either wrap/augment expanded custom-command prompts with the same loop-control instructions, or reject/narrow bare self-paced/commandloops so only fixed-interval command loops are advertised.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
internal/tui/loop_controller_test.go (3)
181-190: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTest doesn't confirm the skipped loop stays scheduled.
Confirms a due loop won't fire behind a picker, but doesn't assert
nextRunAtis untouched afterward. IffireDueLoopIfIdleaccidentally cleared/advancednextRunAton the busy path, this loop would silently stop firing forever and this test would still pass.Suggested addition
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 skipped due loop must keep its nextRunAt so it can fire on a later idle tick") + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/loop_controller_test.go` around lines 181 - 190, The test in fireDueLoopIfIdle only verifies that a due loop does not launch behind an open picker/modal, but it does not verify that the loop remains scheduled. Update TestFireDueLoopSkipsBehindModal in loop_controller_test.go to assert that the loop’s nextRunAt (and any related scheduling state on the returned model from fireDueLoopIfIdle) is unchanged when m.picker is set, so a skipped run cannot be accidentally cleared or advanced.
356-393: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winConfirm-gate test doesn't verify the transcript is actually cleared.
TestLoopClearRequiresConfirmthoroughly checks that loops survive/clear, but the second confirmed/clear(n3 → n4) never asserts the transcript/screen was actually wiped. As written, a bug that swallows the confirmed clear entirely (no-op) would still pass this test since onlyloopLeavePromptand loop count are checked.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/loop_controller_test.go` around lines 356 - 393, TestLoopClearRequiresConfirm only checks confirm state and loop count, so it can miss a confirmed /clear that does nothing. Update the test around the n3 -> n4 transition to also assert the transcript/screen state is actually wiped after the second /clear, using the existing model and Update flow in TestLoopClearRequiresConfirm so a no-op clear will fail.
201-213: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winFooter summary assertion is too weak to catch a missing next-wake time.
The PR requires the footer to show "loop count and next wake time." This test only checks the
"1 loop"prefix and never verifies the wake time actually appears, so a regression that drops the wake time (or formats it wrong) would pass unnoticed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/tui/loop_controller_test.go` around lines 201 - 213, Strengthen TestLoopFooterSummary so it verifies both parts of loopFooterSummary output: the loop count and the next wake time. Update the assertions to check that the returned string still starts with the expected count from loopFooterSummary, and also contains the formatted next-run time derived from m.loops[0].nextRunAt / now so a missing or malformed wake time will fail the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/tui/session.go`:
- Around line 204-212: The resume flow in session switching is clearing loops
even when `/resume latest` or `/resume <currentID>` resolves to the
already-active session. Update the resume handling in session.go so the call to
clearLoopsForSessionSwitch() is guarded by an actual session ID change, and only
emit the “Stopped %d loop(s) tied to the previous session.” row when switching
to a different session.
---
Nitpick comments:
In `@internal/tui/loop_controller_test.go`:
- Around line 181-190: The test in fireDueLoopIfIdle only verifies that a due
loop does not launch behind an open picker/modal, but it does not verify that
the loop remains scheduled. Update TestFireDueLoopSkipsBehindModal in
loop_controller_test.go to assert that the loop’s nextRunAt (and any related
scheduling state on the returned model from fireDueLoopIfIdle) is unchanged when
m.picker is set, so a skipped run cannot be accidentally cleared or advanced.
- Around line 356-393: TestLoopClearRequiresConfirm only checks confirm state
and loop count, so it can miss a confirmed /clear that does nothing. Update the
test around the n3 -> n4 transition to also assert the transcript/screen state
is actually wiped after the second /clear, using the existing model and Update
flow in TestLoopClearRequiresConfirm so a no-op clear will fail.
- Around line 201-213: Strengthen TestLoopFooterSummary so it verifies both
parts of loopFooterSummary output: the loop count and the next wake time. Update
the assertions to check that the returned string still starts with the expected
count from loopFooterSummary, and also contains the formatted next-run time
derived from m.loops[0].nextRunAt / now so a missing or malformed wake time will
fail the test.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 15155f8e-6ba2-44c1-92c3-c25e5a03ca21
📒 Files selected for processing (5)
internal/tui/loop.gointernal/tui/loop_controller_test.gointernal/tui/loop_test.gointernal/tui/model.gointernal/tui/session.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/tui/loop.go
|
bro @Vasanthdev2004 please rebase to main and fix conflicts |
First slice of the /loop feature (#501): the self-contained core with no TUI wiring yet. parseLoopCommand handles the leading-interval, trailing-"every", bare-self-paced, and list/stop forms; loopState models one session-scoped loop with doom-loop + iteration-cap fields; interval/self-pace clamps and duration formatting round it out, with unit tests. Because Zero drives loops off a Go timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported with no whitelist limitation.
…continuation Second slice of #501. Adds the controller and wires it in: - /loop command registered (commands.go) and dispatched to handleLoopCommand. - Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards, loopNextWake/loopDone self-pace channel). - An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a user's queued prompt never collide; a self-perpetuating tea.Tick that stops when no loops remain. - The agentResponseMsg completion seam advances a loop iteration (schedule next wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and done stop conditions, restarting the ticker if other loops remain. - /loop list|stop|stop all, footer summary + list rendering, and a guard that only a prompt or custom /command may loop (not a built-in). Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy.
…r note Third slice of #501. - Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as iterations accrue), with the loopNextWake channel kept as an override for a future explicit model signal. - Persistent footer segment "↻ N loops · next 3:05pm" while loops are active. - /clear notes that loops keep running (it wipes the transcript, not the session) so a loop never silently fires into a cleared screen. - Tests for adaptive cadence + footer summary.
Addresses CodeRabbit review on #502: - advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and stops the loop, so a loop whose turns keep erroring no longer churns to the iteration cap (the doom guard only sees identical non-empty answers). - cancelRun clears activeLoopID and re-arms the interrupted loop for its next cadence, so a cancelled iteration is not left running forever and the next unrelated turn is not misattributed as that loop's completion.
…, leave gate Response to the review. The self-paced path was the real gap: it advertised model-chosen completion but nothing ever wrote the done/next-wake fields, so it was just adaptive polling dressed up. Replaced that with an actual control channel — each self-paced iteration runs a short maintenance playbook and ends its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that advanceLoop parses to stop the loop or pick the next wake. The two dead model fields are gone; done and next-wake now come from the model's output. Rest of the review: - loops stop on a session switch (/new, /resume) instead of firing the previous conversation's prompt into the new one - validateLoopTarget resolves custom /commands up front and rejects unknowns, so a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text - a one-shot confirm gates /clear and /quit while loops are active - age-based auto-expire (24h) next to the iteration cap - the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a single loop) Hardening from a self-review pass over the above: - normalize CRLF and allow a trailing note on the control line, so `LOOP: DONE — all clean` and \r\n replies still stop the loop - disarm the leave-confirm before the queue/exit early returns, so an interposed prompt can't leave it falsely armed - a loop no longer fires behind an open picker/modal; a run launched behind the /resume picker would otherwise complete into whatever session you switch to
…mmand, resume guard - stopLoop re-arms the poll ticker after removing one loop, so stopping one of several no longer strands the rest with no tick scheduled (jatmn). - a bare self-paced /command is rejected with a pointer to the interval form: self-paced drives a free-form goal via the playbook + LOOP: control line, which a custom command's expanded template never carries, so it would silently degrade to adaptive polling (jatmn). - /resume only tears loops down on an actual session-id change; /resume latest or /resume <currentID> can resolve to the already-active session, whose loops are its own, not a "previous" session's (CodeRabbit). - tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake time, the confirmed /clear actually wipes the transcript, plus multi-loop stop keeping the ticker alive and self-paced-/command rejection.
47c799e to
8d1e3b0
Compare
|
Rebased onto main (kept the new Ticker re-arm — you're right, Self-paced (CodeRabbit) Also picked up the three test-strengthening nits: the skipped-behind-modal loop now asserts it stays scheduled, the footer test checks the next-wake time, and the confirmed Build, vet, and the tui suite are green locally, and it's mergeable again. |
jatmn
left a comment
There was a problem hiding this comment.
Thanks for the update. I rechecked the previously discussed paths and do not see any remaining actionable issues from my side.
@Vasanthdev2004 LGTM
Adds
/loop— repeat a prompt or slash command, in two modes. Fixes #501./loop 5m /babysit-prsre-fires every 5 minutes (also a trailing form:/loop watch CI every 2m). Because it runs off a Go timer rather than a cron backend, arbitrary intervals like90sor7mjust work./loop keep tidying the docsruns an iteration, then checks back on an adaptive cadence (2m early, widening to a ~30m heartbeat as it matures) and stops when there's no more progress.Manage with
/loop listand/loop stop [id|all], and a persistent footer segment (↻ N loops · next 3:05pm) keeps a running loop visible.How it works
Loops are foreground and session-scoped — a loop only fires while the session is idle between turns (it never interrupts a streaming turn) and lives with its session. It rides machinery that's already there: an idle poll tick (~1s) fires the earliest due loop through the same seam
launchQueuedMessageIfReadyuses, so a user's queued prompt always wins and the two never collide. When an iteration finishes, theagentResponseMsgcompletion seam schedules the next wake or stops the loop.Safety
A loop can't run away: a max-iteration cap, a doom-loop guard (stops after 5 identical results in a row), and
/loop stop./clearnotes that loops keep running (it wipes the transcript, not the session). Only a prompt or a custom/commandmay loop — not a built-in like/model.Tested
Unit tests for parsing/clamps/cadence, controller tests for the full lifecycle (start → advance → self-paced delay → done/doom/cap → stop → busy-skip), and end-to-end tests that drive
/loopthroughUpdate.go build ./...,go vet, and the fullinternal/tuisuite are green.Deliberately out of scope (follow-ups)
loop_controltool for the model to set its own self-paced delay / done signal — the channel (loopNextWake/loopDone) is already wired; today self-paced uses the adaptive cadence + doom-guard.Summary by CodeRabbit
/loopcommand to repeat prompts or custom/commands on a fixed interval or in self-paced mode.list/statusand stopping loops by id or stopping all./loopusage examples./loopparsing, scheduling, control handling, and confirmation/UI behaviors.