Files
Teknium 1a71782f17 feat(cli): backtrack picker — Esc Esc / /rewind to edit & resend or fork from an earlier prompt
Classic CLI gains a Codex-style backtrack picker. Esc Esc on an idle, empty composer with no
overlay (or /rewind) lists earlier prompts newest first with a preview and turn number.

- Enter: prefill the prompt for editing. Nothing is cut until the edit is submitted; the
  submission rides to the process loop as a BacktrackResend, which re-validates the picked
  turn (same session, same text) and then runs the same durable rewind /undo uses. Esc Esc,
  Ctrl+C, an empty submit, or submitting a slash/! command cancels and cuts nothing.
- f: fork. Runs the existing /branch path, then rewinds only the CHILD to just before the
  picked prompt and prefills it; the parent's rows stay active.
- /rewind N and /rewind fork N do the same without the picker.

Esc Esc with a draft still discards it; mid-turn Esc Esc is unchanged.

undo_last's rewind body moves into CLISessionMixin._rewind_to_user_ordinal (shared by /undo
and the picker; same compaction-carrier handling, agent patching and memory rewound hook).
The picker lives in a new hermes_cli/cli_backtrack.py mixin; cli_tui_mixin.py is untouched.
CommandDef rewind is cli_only (desktop: terminal); desktop registry and parity table regenerated.
2026-11-04 23:06:14 -08:00

279 lines
13 KiB
Python

"""Codex-style backtrack for the classic CLI: Esc Esc on an idle, empty composer (or ``/rewind``)
lists earlier prompts newest first; Enter edits & resends one, ``f`` forks a branch before it.
Edit & resend cuts NOTHING until the edited text is submitted: the submission travels to the
process loop as a :class:`BacktrackResend`, which re-validates the picked turn and only then runs
the same durable rewind ``/undo`` uses (``_rewind_to_user_ordinal``). Cancelling (Esc Esc, Ctrl+C,
an empty submit, or submitting something else) leaves the session as it was. Fork reuses
``/branch`` and rewinds only the child, so the original session's rows stay active.
Mixin bound FIRST in ``HermesCLI``'s bases so its overrides wrap the TUI handlers via ``super()``.
cli.py-internal symbols are imported lazily (import cycle).
"""
from __future__ import annotations
from agent.i18n import t
_LABEL_CHARS = 72
_PREVIEW_LINES = 4
_PREVIEW_CHARS = 80
class BacktrackResend(str):
"""An edited prompt to send after rewinding to the turn it was picked from."""
__slots__ = ("expected", "number", "ordinal", "session_id")
def __new__(cls, text: str, *, ordinal: int, expected: str, session_id, number: int):
obj = super().__new__(cls, text)
obj.ordinal, obj.expected, obj.session_id, obj.number = ordinal, expected, session_id, number
return obj
def _one_line(text: str, limit: int) -> str:
line = " ".join((text or "").split())
return line if len(line) <= limit else line[: limit - 1] + "…"
class CLIBacktrackMixin:
_backtrack_state = None # picker: {"entries": [(ordinal, text)], newest first, "selected", "_scroll_offset"}
_backtrack_edit = None # pending edit & resend: {"ordinal", "expected", "session_id", "number"}
_backtrack_widget = None
# ---- model ---------------------------------------------------------------------------
def _backtrack_prompts(self) -> list[str]:
"""Editable text of every real user turn, oldest first (ordinal = index); the same
predicate /undo counts turns with, so an ordinal here is the ordinal it rewinds."""
from agent.context_compressor import user_originated_turn_view
from agent.message_metadata import typed_prompt_content
from hermes_cli.cli_session_mixin import _user_turn_indices
history = self.conversation_history or []
prompts = []
for index in _user_turn_indices(history):
view = user_originated_turn_view(history[index]) or {}
prompts.append(self._undo_content_to_text(typed_prompt_content(view.get("content"), history[index])))
return prompts
def _backtrack_blocked(self) -> bool:
return bool(self._agent_running or self._command_running or self._clarify_state or self._approval_state
or self._sudo_state or self._secret_state or self._connection_state or self._slash_confirm_state
or self._model_picker_state or self._command_palette_state)
# ---- picker --------------------------------------------------------------------------
def _open_backtrack_picker(self) -> bool:
from cli import _cprint
if self._backtrack_state or self._backtrack_blocked():
return False
prompts = self._backtrack_prompts()
if not prompts:
_cprint(f" {t('cli.backtrack.nothing')}")
return False
self._backtrack_state = {
"entries": list(reversed(list(enumerate(prompts)))), "selected": 0, "_scroll_offset": 0}
self._invalidate(min_interval=0.0)
return True
def _close_backtrack_picker(self) -> None:
self._backtrack_state = None
self._invalidate(min_interval=0.0)
def _backtrack_selected(self):
state = self._backtrack_state or {}
entries = state.get("entries") or []
selected = state.get("selected", 0)
return entries[selected] if 0 <= selected < len(entries) else None
def _get_backtrack_display_fragments(self):
state = self._backtrack_state
if not state:
return []
entries = state["entries"]
total = len(entries)
picked = self._backtrack_selected()
hints = [t("cli.backtrack.keys_hint")]
if picked is not None:
ordinal, text = picked
lines = (text or "").splitlines() or [""]
hints += ["", t("cli.backtrack.preview_label", number=ordinal + 1, total=total)]
hints += [f" {_one_line(line, _PREVIEW_CHARS)}" for line in lines[:_PREVIEW_LINES]]
if len(lines) > _PREVIEW_LINES:
hints.append(f" {t('cli.backtrack.more_lines', count=len(lines) - _PREVIEW_LINES)}")
labels = [f"{ordinal + 1:>3}. {_one_line(text, _LABEL_CHARS)}" for ordinal, text in entries]
return self._render_scroll_list_panel(
state, t("cli.backtrack.title"), hints, labels, min_width=50, max_width=90, indent=" ")
def _backtrack_move(self, delta: int) -> None:
state = self._backtrack_state
if state:
state["selected"] = max(0, min(len(state["entries"]) - 1, state.get("selected", 0) + delta))
self._invalidate(min_interval=0.0)
def _backtrack_choose_edit(self) -> None:
picked = self._backtrack_selected()
self._close_backtrack_picker()
if picked is not None:
self._backtrack_begin_edit(*picked)
def _backtrack_choose_fork(self) -> None:
picked = self._backtrack_selected()
self._close_backtrack_picker()
if picked is not None:
# Branching writes the DB and switches sessions: run it on the process loop like /branch.
self._pending_input.put(f"/rewind fork {picked[0] + 1}")
# ---- edit & resend -------------------------------------------------------------------
def _backtrack_begin_edit(self, ordinal: int, text: str) -> None:
self._backtrack_edit = {"ordinal": ordinal, "expected": text, "session_id": self.session_id,
"number": ordinal + 1}
self._prefill_input_buffer(text)
def _backtrack_cancel_edit(self) -> bool:
from cli import _cprint
if self._backtrack_edit is None:
return False
self._backtrack_edit = None
_cprint(f" {t('cli.backtrack.edit_cancelled')}")
return True
def _backtrack_apply_resend(self, item: BacktrackResend):
"""Rewind to the picked turn, then return the edited text to send (None: nothing sent)."""
from cli import _cprint
prompts = self._backtrack_prompts()
if (item.session_id != self.session_id or item.ordinal >= len(prompts)
or prompts[item.ordinal] != item.expected or self._agent_running):
_cprint(f" {t('cli.backtrack.target_changed', number=item.number)}")
self._prefill_input_buffer(str(item))
return None
rewound = self._rewind_to_user_ordinal(item.ordinal, failure_key="cli.backtrack.rewind_failed")
if rewound is None:
self._prefill_input_buffer(str(item))
return None
_cprint(f" {t('cli.backtrack.rewound', number=item.number, count=rewound[1])}")
return str(item)
# ---- fork ----------------------------------------------------------------------------
def _backtrack_fork(self, ordinal: int) -> None:
"""/branch, then rewind the CHILD to just before ``ordinal`` and prefill that prompt."""
from cli import _cprint
parent_session_id = self.session_id
self._handle_branch_command("/branch")
if self.session_id == parent_session_id:
return # /branch refused and said why; nothing changed
rewound = self._rewind_to_user_ordinal(ordinal, failure_key="cli.backtrack.rewind_failed")
if rewound is None:
return
_cprint(f" {t('cli.backtrack.forked', number=ordinal + 1, parent=parent_session_id)}")
self._prefill_input_buffer(rewound[0])
# ---- /rewind -------------------------------------------------------------------------
def _handle_rewind_command(self, cmd_original: str) -> None:
"""/rewind opens the picker; ``/rewind N`` edits prompt N; ``/rewind fork N`` forks before it."""
from cli import _cprint
if self._agent_running:
return _cprint(f" {t('cli.commands.shared.agent_busy', command='/rewind')}")
args = cmd_original.split()[1:]
fork = bool(args) and args[0].lower() == "fork"
args = args[1:] if fork else args
if not args and not fork:
self._open_backtrack_picker()
return None
if len(args) != 1 or not args[0].isdigit():
return _cprint(f" {t('cli.backtrack.usage')}")
prompts = self._backtrack_prompts()
number = int(args[0])
if not prompts:
return _cprint(f" {t('cli.backtrack.nothing')}")
if not 1 <= number <= len(prompts):
return _cprint(f" {t('cli.backtrack.invalid', value=args[0], total=len(prompts))}")
if fork:
return self._backtrack_fork(number - 1)
self._backtrack_begin_edit(number - 1, prompts[number - 1])
return None
# ---- TUI hooks (wrap CLITuiMixin / CLITuiRuntimeMixin via super()) --------------------
def _tui_handle_double_escape(self, event):
"""Idle + empty composer + no overlay: open the picker. A draft is still discarded (and a
pending edit cancelled with it); mid-turn behaviour is unchanged."""
buf = event.app.current_buffer
if not (buf.text or self._attached_images):
if self._backtrack_edit is not None:
self._backtrack_cancel_edit()
elif not self._backtrack_blocked():
self._open_backtrack_picker()
return None
self._backtrack_cancel_edit()
return super()._tui_handle_double_escape(event)
def _tui_submit_buffer(self, event, buf):
from cli import _looks_like_slash_command
edit = self._backtrack_edit
if edit is None:
return super()._tui_submit_buffer(event, buf)
text = buf.text.strip()
if not text and not self._attached_images:
self._backtrack_cancel_edit()
return None
if (self._agent_running or self._attached_images or _looks_like_slash_command(text)
or text.startswith("!")):
self._backtrack_cancel_edit()
return super()._tui_submit_buffer(event, buf)
self._backtrack_edit = None
self._inline_pastes(buf)
self._pending_input.put(BacktrackResend(buf.text.strip(), **edit))
buf.reset(append_to_history=True)
event.app.invalidate()
return None
def _tui_process_one_input(self, user_input):
if isinstance(user_input, BacktrackResend):
user_input = self._backtrack_apply_resend(user_input)
if user_input is None:
return None
return super()._tui_process_one_input(user_input)
def _tui_clear_or_exit(self, event) -> None:
if self._backtrack_cancel_edit() and not (event.app.current_buffer.text or self._attached_images):
return None # the press cancelled the pending edit; never exit on it
return super()._tui_clear_or_exit(event)
def _tui_handle_ctrl_c(self, event):
if self._backtrack_state:
self._close_backtrack_picker()
return None
return super()._tui_handle_ctrl_c(event)
def _tui_hint_text(self):
hint = super()._tui_hint_text()
if not hint and self._backtrack_edit is not None and not self._backtrack_state:
return [("class:hint", " " + t("cli.backtrack.editing_hint", number=self._backtrack_edit["number"]))]
return hint
def _tui_hint_height(self):
if self._backtrack_edit is not None:
return 1
return super()._tui_hint_height()
def _get_extra_tui_widgets(self) -> list:
extra = super()._get_extra_tui_widgets()
return [self._backtrack_widget, *extra] if self._backtrack_widget is not None else extra
def _tui_build_layout(self, kb):
self._backtrack_widget = self._tui_overlay_widget(self._get_backtrack_display_fragments, "_backtrack_state")
return super()._tui_build_layout(kb)
def _tui_build_key_bindings(self):
"""Picker keys, registered after the generic ones so they win while it is open (the
last matching binding wins); Keys.Any swallows typing so nothing lands in the composer."""
from prompt_toolkit.filters import Condition
from prompt_toolkit.keys import Keys
kb = super()._tui_build_key_bindings()
picker = Condition(lambda: bool(self._backtrack_state))
kb.add(Keys.Any, filter=picker)(lambda event: None)
kb.add("up", filter=picker)(lambda event: self._backtrack_move(-1))
kb.add("down", filter=picker)(lambda event: self._backtrack_move(1))
kb.add("enter", filter=picker, eager=True)(lambda event: self._backtrack_choose_edit())
kb.add("f", filter=picker)(lambda event: self._backtrack_choose_fork())
kb.add("escape", filter=picker, eager=True)(lambda event: self._close_backtrack_picker())
return kb