mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
commit
7651764ce6
4 changed files with 141 additions and 16 deletions
59
cli.py
59
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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
ui-tui/src/app/useSubmission.test.ts
Normal file
34
ui-tui/src/app/useSubmission.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue