From 79af4725829288bf00b5bea5aff3a32996b9704b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:23:40 -0500 Subject: [PATCH] fix(cli,tui): recall real paste content on up-arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large pastes collapse to a placeholder in the composer, but input history stored the placeholder — so up-arrow recall showed a truncated reference (CLI) or lost the content entirely (TUI, where the `[[…]]` label has no backing snip after submit). Store the expanded content in history instead: - CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer before `reset(append_to_history=True)`; also reused by the external editor (dedup). History nav suppresses re-collapse of recalled content. - TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent on label-free text so re-submitting a recalled entry stays stable. --- cli.py | 59 ++++++++++++++++++++++----- tests/cli/test_cli_external_editor.py | 38 +++++++++++++++++ ui-tui/src/app/useSubmission.test.ts | 34 +++++++++++++++ ui-tui/src/app/useSubmission.ts | 26 +++++++++--- 4 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 ui-tui/src/app/useSubmission.test.ts diff --git a/cli.py b/cli.py index 20929befe882..a1544a681dfa 100644 --- a/cli.py +++ b/cli.py @@ -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): diff --git a/tests/cli/test_cli_external_editor.py b/tests/cli/test_cli_external_editor.py index 082c5e40fb89..639449517cb9 100644 --- a/tests/cli/test_cli_external_editor.py +++ b/tests/cli/test_cli_external_editor.py @@ -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 diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts new file mode 100644 index 000000000000..34202104dd47 --- /dev/null +++ b/ui-tui/src/app/useSubmission.test.ts @@ -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) + }) +}) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 881257e386f6..a5c484cc288c 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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() 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(