Plan mode proposes, and you decide whether to carry it out

`plan_submit` records an ordered set of steps and ends the turn. Offered in
Plan mode and nowhere else: it stops the reply, and a model in Auto mode
proposing a plan instead of doing the work would be obeying the wrong
instinct at the worst moment.

The plan is stored on the message rather than parsed back out of the prose,
so the button sends exactly what was proposed. It gets one more request to
say what it proposed and why -- a bubble containing only a card reads as
though the model had nothing to add -- but with the tools withdrawn, so
"one more round" cannot become three rounds of it changing its mind about a
plan somebody is being asked to approve.

Carrying it out switches to Edit, never Auto. The plan was written under a
mode where every command stopped for approval, and a button that also
removed the asking is not the button anybody pressed. It goes back quoted
and attributed, not stated: a plan whose text came out of a file the model
read must not arrive in the most trusted role in the transcript wearing the
reader's authority.

Also closes the rewind gap. Editing or regenerating a turn rewinds the
transcript and not the machine, so `rewound_at` is stamped and the harness
says so. Nothing tries to undo anything out there -- the project directory
is somebody's real working tree, and deleting their work to match a rewound
transcript would be far worse than the inconsistency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 00:24:09 +02:00
parent b6aab8de55
commit 02e60d6c6c
7 changed files with 408 additions and 1 deletions
@@ -193,6 +193,11 @@
leave an empty box under the file. #}
{% endif %}
{% if not streaming and message.plan_json %}
{# What the model proposed in Plan mode, with the way to carry it out. #}
{% include "chat/_plan.html" %}
{% endif %}
{% if not streaming and message.role == "assistant" and message.usage_json %}
{# Above the buttons, not among them: the actions row is things you press. #}
<div class="msg__metrics" id="metrics-{{ message.id }}">
+39
View File
@@ -0,0 +1,39 @@
{% from "_macros.html" import icon %}
{#
A plan the model proposed in Plan mode.
Rendered from `message.plan_json` rather than parsed back out of the prose, so
the Execute button sends exactly what was proposed. Every line is model output
and is escaped; a step is shown as text, never as Markdown, because a plan is
the last thing that should be able to emit a link.
Execute switches the chat to Edit, never Auto -- the plan was written under a
mode where every command stopped for approval, and a button that also removed
the asking is not the button anybody pressed. The confirm dialog says so.
#}
<section class="plan">
<h3 class="plan__title">
{{ icon("check", "icon--sm") }}
{{ message.plan_json.title or "A plan" }}
</h3>
<ol class="plan__steps">
{% for step in message.plan_json.steps %}
<li>{{ step }}</li>
{% endfor %}
</ol>
{% if chat.kind == "agent" %}
<div class="btn-row">
<button class="btn btn--primary btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/execute-plan"
hx-target="#thread" hx-swap="beforeend"
hx-confirm="Switch this chat to Edit mode and carry out this plan? Every command will still be shown to you before it runs."
data-confirm-label="Carry it out"
data-confirm-danger="false">
{{ icon("send", "icon--sm") }} Carry out this plan
</button>
<span class="plan__note">Switches to Edit — commands still ask.</span>
</div>
{% endif %}
</section>