Merge pull request #68390 from NousResearch/bb/paste-history-recall

fix(cli,tui): recall real paste content on up-arrow
This commit is contained in:
brooklyn! 2026-07-20 23:33:25 -05:00 committed by GitHub
commit 7651764ce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 16 deletions

59
cli.py
View file

@ -6093,15 +6093,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}")
return False
try:
existing_text = getattr(target_buffer, "text", "")
expanded_text = self._expand_paste_references(existing_text)
if expanded_text != existing_text and hasattr(target_buffer, "text"):
self._skip_paste_collapse = True
target_buffer.text = expanded_text
if hasattr(target_buffer, "cursor_position"):
target_buffer.cursor_position = len(expanded_text)
# Set skip flag (again) so the text-change event fired when the
# editor closes does not re-collapse the returned content.
# Inline pastes so the editor (and the draft it submits) sees real
# content; skip flag unconditionally so the editor-close text-change
# doesn't re-collapse it, even when there was nothing to inline.
self._inline_pastes(target_buffer)
self._skip_paste_collapse = True
# Open the editor, then submit the saved draft on a clean exit —
# matching the TUI's Ctrl+G (openEditor), which sends the buffer
@ -6173,6 +6168,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if app is not None:
app.invalidate()
def _inline_pastes(self, buffer) -> None:
"""Replace collapsed-paste placeholders in ``buffer`` with real content.
A big paste shows as a compact ``[Pasted text #N -> file]`` placeholder,
but history recall and the external editor need the actual text a bare
reference is useless once the file is gone or on another machine. Inlining
before ``reset(append_to_history=True)`` also lets prompt_toolkit persist
the content through its normal path. Sets ``_skip_paste_collapse`` so the
ensuing text-change doesn't re-collapse it.
"""
try:
existing = getattr(buffer, "text", "")
expanded = self._expand_paste_references(existing)
if expanded != existing and hasattr(buffer, "text"):
self._skip_paste_collapse = True
buffer.text = expanded
if hasattr(buffer, "cursor_position"):
buffer.cursor_position = len(expanded)
except Exception:
logger.debug("Failed to inline paste placeholders", exc_info=True)
def _reset_input_buffer(self, buffer) -> None:
"""Clear an input buffer after a programmatic submit (best-effort)."""
try:
@ -13369,6 +13385,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
pass
else:
self._pending_input.put(payload)
# History stores real pasted content, not the placeholder, so
# up-arrow recall restores the actual text.
self._inline_pastes(event.app.current_buffer)
event.app.current_buffer.reset(append_to_history=True)
_bind_prompt_submit_keys(kb, handle_enter)
@ -13579,15 +13598,33 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
)
def _recall_without_recollapse(buf, move):
"""Run a history-navigation move, suppressing paste-collapse.
Recalled history can hold the full text of a paste that was
collapsed to a placeholder at submit time. Loading it back into the
buffer looks exactly like a fresh large paste to ``_on_text_changed``
and would be re-collapsed. Set the skip flag around the move; if the
move didn't change the text (plain cursor movement), clear the flag
so a later real paste still collapses.
"""
before = buf.text
self._skip_paste_collapse = True
move()
if buf.text == before:
self._skip_paste_collapse = False
@kb.add('up', filter=_normal_input)
def history_up(event):
"""Up arrow: browse history when on first line, else move cursor up."""
event.app.current_buffer.auto_up(count=event.arg)
buf = event.app.current_buffer
_recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg))
@kb.add('down', filter=_normal_input)
def history_down(event):
"""Down arrow: browse history when on last line, else move cursor down."""
event.app.current_buffer.auto_down(count=event.arg)
buf = event.app.current_buffer
_recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg))
@kb.add('c-l')
def handle_ctrl_l(event):

View file

@ -103,3 +103,41 @@ def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path)
# Flag is consumed by _on_text_changed, but since no handler is attached
# in tests it stays True until the handler resets it.
assert cli_obj._skip_paste_collapse is True
def test_inline_pastes_stores_full_content(tmp_path):
"""History should recall the actual pasted text, not the placeholder."""
cli_obj = _make_cli()
paste_file = tmp_path / "paste.txt"
paste_file.write_text("line one\nline two", encoding="utf-8")
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines \u2192 {paste_file}]")
cli_obj._inline_pastes(buffer)
assert buffer.text == "line one\nline two"
assert buffer.cursor_position == len("line one\nline two")
# Skip flag set so the resulting text-change doesn't re-collapse.
assert cli_obj._skip_paste_collapse is True
def test_inline_pastes_leaves_plain_text_untouched():
"""No placeholder → buffer text and collapse flag are unchanged."""
cli_obj = _make_cli()
buffer = _FakeBuffer(text="just a normal message")
cli_obj._inline_pastes(buffer)
assert buffer.text == "just a normal message"
assert cli_obj._skip_paste_collapse is False
def test_inline_pastes_missing_file_keeps_placeholder(tmp_path):
"""A recalled reference whose file is gone stays as the placeholder."""
cli_obj = _make_cli()
placeholder = f"[Pasted text #1: 2 lines \u2192 {tmp_path / 'gone.txt'}]"
buffer = _FakeBuffer(text=placeholder)
cli_obj._inline_pastes(buffer)
assert buffer.text == placeholder
assert cli_obj._skip_paste_collapse is False

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { PasteSnippet } from './interfaces.js'
import { expandSnips } from './useSubmission.js'
const snip = (label: string, text: string): PasteSnippet => ({ label, text })
describe('expandSnips (paste history recall)', () => {
it('replaces a collapsed paste label with its full content', () => {
const label = '[[ hello.. [3 lines] .. world ]]'
const full = `here: ${label} done`
const expand = expandSnips([snip(label, 'hello\nfoo\nworld')])
expect(expand(full)).toBe('here: hello\nfoo\nworld done')
})
it('is a no-op for already-expanded / label-free text (recall round-trip)', () => {
const expanded = 'hello\nfoo\nworld'
// Re-submitting a recalled history entry has no snips and no labels.
expect(expandSnips([])(expanded)).toBe(expanded)
})
it('expands repeated identical labels in submission order', () => {
const label = '[[ x [1 lines] ]]'
const expand = expandSnips([snip(label, 'first'), snip(label, 'second')])
expect(expand(`${label} then ${label}`)).toBe('first then second')
})
it('leaves an unmatched label intact', () => {
const label = '[[ orphan [2 lines] ]]'
expect(expandSnips([])(label)).toBe(label)
})
})

View file

@ -16,7 +16,7 @@ import { getUiState, patchUiState } from './uiStore.js'
const DOUBLE_ENTER_MS = 450
const expandSnips = (snips: PasteSnippet[]) => {
export const expandSnips = (snips: PasteSnippet[]) => {
const byLabel = new Map<string, string[]>()
for (const { label, text } of snips) {
@ -217,9 +217,14 @@ export function useSubmission(opts: UseSubmissionOptions) {
return
}
// History stores expanded paste content, not the `[[…]]` label: snips
// are cleared on submit, so recall must be self-contained. Idempotent on
// label-free text, so re-submitting a recalled entry stays stable.
const toHistory = expandSnips(composerState.pasteSnips)(full)
if (looksLikeSlashCommand(full)) {
appendMessage({ kind: 'slash', role: 'system', text: full })
composerActions.pushHistory(full)
composerActions.pushHistory(toHistory)
slashRef.current(full)
composerActions.clearIn()
@ -235,7 +240,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
const live = getUiState()
if (!live.sid) {
composerActions.pushHistory(full)
composerActions.pushHistory(toHistory)
composerActions.enqueue(full)
composerActions.clearIn()
@ -271,7 +276,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
return sendQueued(picked)
}
composerActions.pushHistory(full)
composerActions.pushHistory(toHistory)
if (getUiState().busy) {
return handleBusyInput(full)
@ -285,7 +290,18 @@ export function useSubmission(opts: UseSubmissionOptions) {
send(full)
},
[appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef]
[
appendMessage,
composerActions,
composerRefs,
composerState.pasteSnips,
handleBusyInput,
interpolate,
send,
sendQueued,
shellExec,
slashRef
]
)
const submit = useCallback(