feat(undo): /undo [N] backs up N user turns with prefill + soft-delete
Extends the existing /undo command from a single in-memory exchange removal into a full rewind: back up N user turns (default 1), soft-delete the truncated rows in SessionDB (active=0, kept for audit, hidden from re-prompts and search), notify memory providers, and prefill the composer with the backed-up message text for editing — CLI and TUI. Reuses the SessionDB rewind primitives, the on_session_switch(rewound=True) memory hook, and the TUI command.dispatch prefill payload from SaguaroDev's #21910 work, wired to /undo [N] instead of a separate /rewind picker. - cli.py: undo_last(n, prefill) — in-memory truncate + SQLite soft-delete + agent surgery (system-prompt invalidate, flush-index reset) + memory notify + editable buffer prefill; /undo dispatch parses optional count; checkpoint-rollback caller passes prefill=False - tui_gateway/server.py: command.dispatch undo branch (was rewind) parses count, picks Nth-from-last user turn, clamps to oldest - commands.py: /undo gains [N] args_hint - tests: rename + expand TUI suite (multi-turn, clamp, invalid-count) - release.py: AUTHOR_MAP entry for SaguaroDev Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com>
This commit is contained in:
183
cli.py
183
cli.py
@ -5559,7 +5559,7 @@ class HermesCLI:
|
||||
# Also undo the last conversation turn so the agent's context
|
||||
# matches the restored filesystem state
|
||||
if self.conversation_history:
|
||||
self.undo_last()
|
||||
self.undo_last(prefill=False)
|
||||
print(" Chat turn undone to match restored file state.")
|
||||
else:
|
||||
print(f" ❌ {result['error']}")
|
||||
@ -7103,37 +7103,156 @@ class HermesCLI:
|
||||
print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"")
|
||||
return last_message
|
||||
|
||||
def undo_last(self):
|
||||
"""Remove the last user/assistant exchange from conversation history.
|
||||
|
||||
Walks backwards and removes all messages from the last user message
|
||||
onward (including assistant responses, tool calls, etc.).
|
||||
def undo_last(self, n: int = 1, prefill: bool = True):
|
||||
"""Back up N user turns: truncate history, soft-delete on disk, prefill.
|
||||
|
||||
Walks backwards N user messages and discards everything from the
|
||||
Nth-from-last user message onward (its assistant response, tool
|
||||
calls, etc.). ``n`` defaults to 1 (the last exchange); ``/undo 3``
|
||||
backs up three user turns. If ``n`` exceeds the number of user
|
||||
turns, it backs up to the oldest one.
|
||||
|
||||
Beyond the in-memory ``conversation_history`` slice, this also:
|
||||
• soft-deletes the truncated rows in SessionDB (``active=0``) so
|
||||
they're hidden from re-prompts and search but kept for audit;
|
||||
• notifies memory providers via ``on_session_switch(rewound=True)``;
|
||||
• mirrors /branch's agent surgery (system-prompt invalidation +
|
||||
flush-index reset);
|
||||
• when ``prefill`` is set and an input buffer is available,
|
||||
pre-fills the composer with the backed-up message text so it
|
||||
can be edited and resubmitted.
|
||||
|
||||
``prefill=False`` is used by callers that drive the undo
|
||||
programmatically (e.g. checkpoint rollback) and don't want to
|
||||
touch the user's input buffer.
|
||||
"""
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to undo.")
|
||||
return
|
||||
|
||||
# Walk backwards to find the last user message
|
||||
last_user_idx = None
|
||||
|
||||
if n < 1:
|
||||
n = 1
|
||||
|
||||
# Walk backwards collecting the indices of the last N user messages.
|
||||
user_indices = []
|
||||
for i in range(len(self.conversation_history) - 1, -1, -1):
|
||||
if self.conversation_history[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
|
||||
if last_user_idx is None:
|
||||
user_indices.append(i)
|
||||
if len(user_indices) >= n:
|
||||
break
|
||||
|
||||
if not user_indices:
|
||||
print("(._.) No user message found to undo.")
|
||||
return
|
||||
|
||||
# Count how many messages we're removing
|
||||
removed_count = len(self.conversation_history) - last_user_idx
|
||||
removed_msg = self.conversation_history[last_user_idx].get("content", "")
|
||||
|
||||
# Truncate history to before the last user message
|
||||
self.conversation_history = self.conversation_history[:last_user_idx]
|
||||
|
||||
print(f"(^_^)b Undid {removed_count} message(s). Removed: \"{removed_msg[:60]}{'...' if len(removed_msg) > 60 else ''}\"")
|
||||
|
||||
# The oldest of the collected user messages is our truncation point.
|
||||
cut_idx = user_indices[-1]
|
||||
turns_undone = len(user_indices)
|
||||
|
||||
removed_count = len(self.conversation_history) - cut_idx
|
||||
removed_msg = self.conversation_history[cut_idx].get("content", "")
|
||||
removed_text = self._undo_content_to_text(removed_msg)
|
||||
|
||||
# Truncate the in-memory history to before that user message.
|
||||
self.conversation_history = self.conversation_history[:cut_idx]
|
||||
|
||||
# Soft-delete the truncated rows on disk so re-prompts and search
|
||||
# see the clean transcript while the rows survive for audit.
|
||||
rewound_rows = 0
|
||||
if self._session_db is not None and self.session_id:
|
||||
try:
|
||||
recents = self._session_db.list_recent_user_messages(
|
||||
self.session_id, limit=max(turns_undone, 10)
|
||||
)
|
||||
if recents:
|
||||
target_idx = min(turns_undone - 1, len(recents) - 1)
|
||||
target_id = recents[target_idx]["id"]
|
||||
result = self._session_db.rewind_to_message(
|
||||
self.session_id, target_id
|
||||
)
|
||||
rewound_rows = result.get("rewound_count", 0)
|
||||
# Prefer the DB's decoded target text for the prefill —
|
||||
# it's the canonical persisted copy.
|
||||
db_text = self._undo_content_to_text(
|
||||
(result.get("target_message") or {}).get("content")
|
||||
)
|
||||
if db_text:
|
||||
removed_text = db_text
|
||||
except ValueError as e:
|
||||
# Non-user target / cross-session — keep the in-memory undo
|
||||
# but skip the soft-delete; surface a debug-level note.
|
||||
logger.debug("undo: soft-delete skipped: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("undo: soft-delete failed: %s", e)
|
||||
|
||||
# Agent surgery: invalidate the system-prompt cache and reset the
|
||||
# flush index so the next turn re-flushes from the truncated head.
|
||||
if self.agent is not None:
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
try:
|
||||
self.agent._invalidate_system_prompt()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
try:
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
# Notify memory providers — same hook /branch fires, with the
|
||||
# rewound flag so per-turn document caches invalidate (#6672, #21910).
|
||||
try:
|
||||
_mm = getattr(self.agent, "_memory_manager", None)
|
||||
if _mm is not None and self.session_id:
|
||||
_mm.on_session_switch(
|
||||
self.session_id,
|
||||
parent_session_id="",
|
||||
reset=False,
|
||||
rewound=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
turn_word = "turn" if turns_undone == 1 else "turns"
|
||||
msg_count = rewound_rows or removed_count
|
||||
print(
|
||||
f"(^_^)b Undid {turns_undone} {turn_word} ({msg_count} message(s)). "
|
||||
f"Backed up to: \"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}\""
|
||||
)
|
||||
remaining = len(self.conversation_history)
|
||||
print(f" {remaining} message(s) remaining in history.")
|
||||
|
||||
# Pre-fill the composer with the backed-up message so the user can
|
||||
# edit and resubmit (Claude-Code-style). Editable, not auto-sent.
|
||||
if prefill and removed_text:
|
||||
self._prefill_input_buffer(removed_text)
|
||||
|
||||
@staticmethod
|
||||
def _undo_content_to_text(content) -> str:
|
||||
"""Flatten message content (str or content-part list) to plain text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
return "\n".join(t for t in parts if t)
|
||||
return ""
|
||||
|
||||
def _prefill_input_buffer(self, text: str) -> None:
|
||||
"""Place ``text`` in the active prompt_toolkit buffer, editable."""
|
||||
app = getattr(self, "_app", None)
|
||||
if app is None:
|
||||
return
|
||||
try:
|
||||
buf = app.current_buffer
|
||||
buf.text = text
|
||||
if hasattr(buf, "cursor_position"):
|
||||
buf.cursor_position = len(text)
|
||||
app.invalidate()
|
||||
except Exception as e:
|
||||
logger.debug("undo: prefill buffer failed: %s", e)
|
||||
|
||||
def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None:
|
||||
"""Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly."""
|
||||
@ -8599,13 +8718,29 @@ class HermesCLI:
|
||||
# Re-queue the message so process_loop sends it to the agent
|
||||
self._pending_input.put(retry_msg)
|
||||
elif canonical == "undo":
|
||||
# Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
|
||||
_undo_n = 1
|
||||
_undo_parts = cmd_original.split()
|
||||
if len(_undo_parts) > 1:
|
||||
try:
|
||||
_undo_n = int(_undo_parts[1])
|
||||
except ValueError:
|
||||
print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.")
|
||||
return
|
||||
if _undo_n < 1:
|
||||
_undo_n = 1
|
||||
_undo_desc = (
|
||||
"This removes the last user/assistant exchange from history."
|
||||
if _undo_n == 1
|
||||
else f"This removes the last {_undo_n} user turns from history."
|
||||
)
|
||||
if self._confirm_destructive_slash(
|
||||
"undo",
|
||||
"This removes the last user/assistant exchange from history.",
|
||||
_undo_desc,
|
||||
cmd_original=cmd_original,
|
||||
) is None:
|
||||
return
|
||||
self.undo_last()
|
||||
self.undo_last(_undo_n)
|
||||
elif canonical == "branch":
|
||||
self._handle_branch_command(cmd_original)
|
||||
elif canonical == "save":
|
||||
|
||||
@ -78,7 +78,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("save", "Save the current conversation", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
||||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||||
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
|
||||
args_hint="[N]"),
|
||||
CommandDef("title", "Set a title for the current session", "Session",
|
||||
args_hint="[name]"),
|
||||
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
||||
|
||||
@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
|
||||
"zhipengli@thebrainly.ai": "a1245582339",
|
||||
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
|
||||
"drpelagik@gmail.com": "SeaXen",
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
"""Tests for /rewind handling in tui_gateway.
|
||||
"""Tests for /undo handling in tui_gateway.
|
||||
|
||||
The TUI routes ``/rewind`` through ``command.dispatch`` (it's in
|
||||
The TUI routes ``/undo`` through ``command.dispatch`` (it's in
|
||||
``_PENDING_INPUT_COMMANDS`` because the CLI handler queues input the
|
||||
slash-worker subprocess can't read). The server handles it directly,
|
||||
mutates SessionDB to soft-delete rows, refreshes the in-memory session
|
||||
history, fires the memory-provider hook with ``rewound=True``, and
|
||||
returns ``{"type": "prefill", "message": <text>, "notice": ...}`` so
|
||||
the Ink client drops the message into the composer for editing.
|
||||
See issue #21910.
|
||||
|
||||
``/undo N`` backs up N user turns at once (default 1). See issue #21910.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -57,8 +58,8 @@ def db(hermes_home):
|
||||
@pytest.fixture()
|
||||
def session_with_history(server, db):
|
||||
"""Build a session with 3 user turns + assistant replies persisted in DB."""
|
||||
sid = "sid-rewind"
|
||||
session_key = "tui-rewind-1"
|
||||
sid = "sid-undo"
|
||||
session_key = "tui-undo-1"
|
||||
db.create_session(session_key, source="tui")
|
||||
for i in range(1, 4):
|
||||
db.append_message(session_key, "user", f"question {i}")
|
||||
@ -87,20 +88,20 @@ def _call(server, method, **params):
|
||||
return server._methods[method](1, params)
|
||||
|
||||
|
||||
def test_rewind_returns_prefill_with_target_text(server, session_with_history):
|
||||
def test_undo_returns_prefill_with_target_text(server, session_with_history):
|
||||
sid, session_key, s, agent = session_with_history
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="rewind", arg="")
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
||||
result = resp["result"]
|
||||
assert result["type"] == "prefill"
|
||||
# v1 auto-picks the most recent user turn — "question 3"
|
||||
# Default /undo backs up one user turn — "question 3"
|
||||
assert result["message"] == "question 3"
|
||||
assert "Rewound" in result["notice"]
|
||||
assert "Undid" in result["notice"]
|
||||
|
||||
|
||||
def test_rewind_truncates_in_memory_history(server, session_with_history, db):
|
||||
def test_undo_truncates_in_memory_history(server, session_with_history, db):
|
||||
sid, session_key, s, agent = session_with_history
|
||||
_call(server, "command.dispatch", session_id=sid, name="rewind", arg="")
|
||||
# After rewinding to "question 3", active history should be 4 rows:
|
||||
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
||||
# After undoing to "question 3", active history should be 4 rows:
|
||||
# user q1, asst a1, user q2, asst a2
|
||||
assert len(s["history"]) == 4
|
||||
roles = [m["role"] for m in s["history"]]
|
||||
@ -109,14 +110,42 @@ def test_rewind_truncates_in_memory_history(server, session_with_history, db):
|
||||
assert s["history_version"] == 1
|
||||
|
||||
|
||||
def test_rewind_soft_deletes_rows_in_db(server, session_with_history, db):
|
||||
def test_undo_n_backs_up_multiple_turns(server, session_with_history, db):
|
||||
"""/undo 2 backs up two user turns to "question 2"."""
|
||||
sid, session_key, s, agent = session_with_history
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="2")
|
||||
result = resp["result"]
|
||||
assert result["type"] == "prefill"
|
||||
assert result["message"] == "question 2"
|
||||
assert "2 turns" in result["notice"]
|
||||
# Active history truncated to user q1 + asst a1
|
||||
assert len(s["history"]) == 2
|
||||
assert [m["role"] for m in s["history"]] == ["user", "assistant"]
|
||||
|
||||
|
||||
def test_undo_n_clamps_to_oldest_turn(server, session_with_history, db):
|
||||
"""/undo with N larger than the number of user turns backs up to the oldest."""
|
||||
sid, session_key, s, agent = session_with_history
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="99")
|
||||
result = resp["result"]
|
||||
assert result["message"] == "question 1"
|
||||
assert len(s["history"]) == 0
|
||||
|
||||
|
||||
def test_undo_rejects_invalid_count(server, session_with_history):
|
||||
sid, _, _, _ = session_with_history
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="abc")
|
||||
assert "error" in resp
|
||||
assert "invalid count" in resp["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_undo_soft_deletes_rows_in_db(server, session_with_history, db):
|
||||
sid, session_key, _, _ = session_with_history
|
||||
_call(server, "command.dispatch", session_id=sid, name="rewind", arg="")
|
||||
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
||||
# All rows still present
|
||||
all_rows = db.get_messages(session_key, include_inactive=True)
|
||||
assert len(all_rows) == 6
|
||||
# 2 inactive (the "question 3" row + its trailing siblings — here just
|
||||
# "question 3" + "answer 3", since target was the q3 user row).
|
||||
# 2 inactive (the "question 3" row + its trailing "answer 3").
|
||||
active = [r for r in all_rows if r["active"] == 1]
|
||||
assert len(active) == 4
|
||||
# rewind_count bumped
|
||||
@ -124,9 +153,9 @@ def test_rewind_soft_deletes_rows_in_db(server, session_with_history, db):
|
||||
assert sess["rewind_count"] == 1
|
||||
|
||||
|
||||
def test_rewind_notifies_memory_provider(server, session_with_history):
|
||||
def test_undo_notifies_memory_provider(server, session_with_history):
|
||||
sid, session_key, _, agent = session_with_history
|
||||
_call(server, "command.dispatch", session_id=sid, name="rewind", arg="")
|
||||
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
||||
agent._memory_manager.on_session_switch.assert_called_once()
|
||||
args, kwargs = agent._memory_manager.on_session_switch.call_args
|
||||
assert args[0] == session_key
|
||||
@ -134,21 +163,21 @@ def test_rewind_notifies_memory_provider(server, session_with_history):
|
||||
assert kwargs["reset"] is False
|
||||
|
||||
|
||||
def test_rewind_refuses_when_session_busy(server, session_with_history):
|
||||
def test_undo_refuses_when_session_busy(server, session_with_history):
|
||||
sid, _, s, _ = session_with_history
|
||||
s["running"] = True
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="rewind", arg="")
|
||||
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="")
|
||||
assert "error" in resp
|
||||
assert "busy" in resp["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_rewind_errors_when_no_active_session(server):
|
||||
resp = _call(server, "command.dispatch", session_id="no-such-sid", name="rewind", arg="")
|
||||
def test_undo_errors_when_no_active_session(server):
|
||||
resp = _call(server, "command.dispatch", session_id="no-such-sid", name="undo", arg="")
|
||||
assert "error" in resp
|
||||
assert "no active session" in resp["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_rewind_in_pending_input_commands(server):
|
||||
"""Registry sanity: /rewind must be in _PENDING_INPUT_COMMANDS so
|
||||
def test_undo_in_pending_input_commands(server):
|
||||
"""Registry sanity: /undo must be in _PENDING_INPUT_COMMANDS so
|
||||
slash.exec rejects it and the TUI falls through to command.dispatch."""
|
||||
assert "rewind" in server._PENDING_INPUT_COMMANDS
|
||||
assert "undo" in server._PENDING_INPUT_COMMANDS
|
||||
@ -5496,7 +5496,7 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
|
||||
"steer",
|
||||
"plan",
|
||||
"goal",
|
||||
"rewind",
|
||||
"undo",
|
||||
}
|
||||
)
|
||||
|
||||
@ -5882,39 +5882,50 @@ def _(rid, params: dict) -> dict:
|
||||
{"type": "send", "notice": notice, "message": state.goal},
|
||||
)
|
||||
|
||||
if name == "rewind":
|
||||
# /rewind: pick the most-recent user message and prefill the
|
||||
# composer with its text after soft-deleting everything that
|
||||
# came after it in the transcript. v1 auto-picks the latest
|
||||
# user turn (Claude-Code-style single-step undo); a multi-step
|
||||
# picker UI is tracked as a follow-up to issue #21910.
|
||||
if name == "undo":
|
||||
# /undo [N]: back up N user turns (default 1), soft-delete the
|
||||
# truncated rows on disk, and prefill the composer with the text
|
||||
# of the user message we backed up to so it can be edited and
|
||||
# resubmitted. N=1 is the Claude-Code-style single-step undo;
|
||||
# /undo 3 backs up three user turns at once. See issue #21910.
|
||||
if not session:
|
||||
return _err(rid, 4001, "no active session to rewind")
|
||||
return _err(rid, 4001, "no active session to undo")
|
||||
if session.get("running"):
|
||||
return _err(
|
||||
rid, 4009, "session busy — /interrupt the current turn before /rewind"
|
||||
rid, 4009, "session busy — /interrupt the current turn before /undo"
|
||||
)
|
||||
db = _get_db()
|
||||
if db is None:
|
||||
return _db_unavailable_error(rid, code=5008)
|
||||
session_key = session.get("session_key", "")
|
||||
if not session_key:
|
||||
return _err(rid, 4001, "no session key for rewind")
|
||||
return _err(rid, 4001, "no session key for undo")
|
||||
# Parse the optional count argument (e.g. "/undo 3" → 3).
|
||||
n = 1
|
||||
arg_str = (arg or "").strip()
|
||||
if arg_str:
|
||||
try:
|
||||
n = int(arg_str.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N")
|
||||
if n < 1:
|
||||
n = 1
|
||||
try:
|
||||
recents = db.list_recent_user_messages(session_key, limit=10)
|
||||
recents = db.list_recent_user_messages(session_key, limit=max(n, 10))
|
||||
except Exception as e:
|
||||
return _err(rid, 5008, f"rewind: failed to load history: {e}")
|
||||
return _err(rid, 5008, f"undo: failed to load history: {e}")
|
||||
if not recents:
|
||||
return _err(rid, 4018, "no user messages to rewind to")
|
||||
# v1: auto-pick the most recent user turn. The Ink UI does not
|
||||
# yet host a dedicated picker overlay (#21910 follow-up).
|
||||
target_id = recents[0]["id"]
|
||||
return _err(rid, 4018, "no user messages to undo")
|
||||
# recents[0] is the most-recent user turn; pick the Nth-from-last.
|
||||
# If N exceeds the number of user turns, back up to the oldest.
|
||||
target_idx = min(n - 1, len(recents) - 1)
|
||||
target_id = recents[target_idx]["id"]
|
||||
try:
|
||||
result = db.rewind_to_message(session_key, target_id)
|
||||
except ValueError as e:
|
||||
return _err(rid, 4004, f"rewind: {e}")
|
||||
return _err(rid, 4004, f"undo: {e}")
|
||||
except Exception as e:
|
||||
return _err(rid, 5008, f"rewind: {e}")
|
||||
return _err(rid, 5008, f"undo: {e}")
|
||||
# Reload the active-only transcript into the in-memory session
|
||||
# history so subsequent turns see the truncated view.
|
||||
try:
|
||||
@ -5961,8 +5972,10 @@ def _(rid, params: dict) -> dict:
|
||||
if not isinstance(target_text, str):
|
||||
target_text = ""
|
||||
rewound_count = result.get("rewound_count", 0)
|
||||
turns_undone = target_idx + 1
|
||||
turn_word = "turn" if turns_undone == 1 else "turns"
|
||||
notice = (
|
||||
f"↶ Rewound {rewound_count} message(s). "
|
||||
f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). "
|
||||
"Edit and resubmit, or send a new message."
|
||||
)
|
||||
return _ok(
|
||||
|
||||
@ -121,8 +121,8 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
||||
}
|
||||
|
||||
if (d.type === 'prefill') {
|
||||
// /rewind returns prefill: drop the chosen text into the
|
||||
// composer so the user can edit and resubmit, instead of
|
||||
// /undo returns prefill: drop the backed-up message text into
|
||||
// the composer so the user can edit and resubmit, instead of
|
||||
// submitting it immediately like 'send'.
|
||||
if (d.notice?.trim()) {
|
||||
sys(d.notice)
|
||||
|
||||
Reference in New Issue
Block a user