From 2517917de34eeb6a40f5a17a2e59d9746803dfa5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 25 May 2026 23:49:01 -0700 Subject: [PATCH] fix(cli): restore fallback paste collapse + handle long single-line pastes (#32447) Follow-up to #32087 after community report from @ethernet that 8000-char single-line pastes get dumped raw into the input box. A) Fallback regression revert paste_collapse_threshold_fallback default: 0 -> 5 #32087 disabled the fallback handler by default. The fallback path has been always-on with line_count >= 5 since #3065 (March 2026); the previous shape was the salvaged contributor's design and didn't match pre-existing behavior for terminals without bracketed paste support (Windows terminals, some SSH setups). Restoring the original on-by-default. B) Long single-line paste guard New config key: paste_collapse_char_threshold (default 2000) Bracketed-paste handler and fallback handler now BOTH collapse when line count >= line threshold OR total char length >= char threshold. Catches the case ethernet hit: ~8000 chars of minified JSON / log output on a single line dumped raw into the buffer. TUI mirrors the same config via uiStore.pasteCollapseChars. Set 0 to disable. Defaults verified: paste_collapse_threshold: 5 paste_collapse_threshold_fallback: 5 paste_collapse_char_threshold: 2000 Tests: tests/hermes_cli/test_config.py: 87/87 pass ui-tui useConfigSync.test.ts: 34/34 pass ui-tui useComposerState.test.ts: 9/9 pass tsc: 0 new errors in touched files --- cli.py | 12 +++++++++--- hermes_cli/config.py | 24 ++++++++++++++++++------ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/app/useComposerState.ts | 5 ++++- ui-tui/src/app/useConfigSync.ts | 12 ++++++++++++ ui-tui/src/gatewayTypes.ts | 2 +- 7 files changed, 46 insertions(+), 11 deletions(-) diff --git a/cli.py b/cli.py index 93050a070d8..c05c361a7c0 100644 --- a/cli.py +++ b/cli.py @@ -13352,7 +13352,10 @@ class HermesCLI: line_count = pasted_text.count('\n') buf = event.current_buffer threshold = self.config.get("paste_collapse_threshold", 5) - if threshold > 0 and line_count >= threshold and not buf.text.strip().startswith('/'): + char_threshold = self.config.get("paste_collapse_char_threshold", 2000) + lines_hit = threshold > 0 and line_count >= threshold + chars_hit = char_threshold > 0 and len(pasted_text) >= char_threshold + if (lines_hit or chars_hit) and not buf.text.strip().startswith('/'): _paste_counter[0] += 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) @@ -13521,8 +13524,11 @@ class HermesCLI: newlines_added = line_count - _prev_newline_count[0] _prev_newline_count[0] = line_count is_paste = chars_added > 1 or newlines_added >= 4 - threshold = self.config.get("paste_collapse_threshold_fallback", 0) - if threshold > 0 and line_count >= threshold and is_paste and not text.startswith('/'): + threshold = self.config.get("paste_collapse_threshold_fallback", 5) + char_threshold = self.config.get("paste_collapse_char_threshold", 2000) + lines_hit = threshold > 0 and line_count >= threshold + chars_hit = char_threshold > 0 and len(text) >= char_threshold + if (lines_hit or chars_hit) and is_paste and not text.startswith('/'): _paste_counter[0] += 1 paste_dir = _hermes_home / "pastes" paste_dir.mkdir(parents=True, exist_ok=True) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c292ab9a05e..475b6ceb55f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1905,13 +1905,25 @@ DEFAULT_CONFIG = { }, # Paste collapse thresholds (TUI + CLI). - # collapse_threshold: paste collapses to a file reference when line count - # exceeds this value (bracketed paste, safe: appends to existing text). - # collapse_threshold_fallback: same but for the fallback heuristic used - # by terminals without bracketed paste support (destructive: replaces - # entire buffer). 0 = disabled. + # + # paste_collapse_threshold (default 5) + # Bracketed-paste handler. Pastes with this many newlines or more + # collapse to a file reference. Set 0 to disable. + # + # paste_collapse_threshold_fallback (default 5) + # Fallback heuristic for terminals without bracketed paste support. + # Same line count test but heuristically gated by chars-added / + # newlines-added to avoid false positives from normal typing. + # Set 0 to disable. + # + # paste_collapse_char_threshold (default 2000) + # Long single-line paste guard. Pastes whose total char length + # reaches this value collapse to a file reference even if line + # count is below the line threshold. Catches the "8000 chars of + # minified JSON / log output on one line" case. Set 0 to disable. "paste_collapse_threshold": 5, - "paste_collapse_threshold_fallback": 0, + "paste_collapse_threshold_fallback": 5, + "paste_collapse_char_threshold": 2000, # Config schema version - bump this when adding new required fields diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 2a39ffeb52e..452f833fafd 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -106,6 +106,7 @@ export interface UiState { inlineDiffs: boolean mouseTracking: MouseTrackingMode pasteCollapseLines: number + pasteCollapseChars: number sections: SectionVisibility showCost: boolean diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index b449736d3d0..a235bf08c61 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -18,6 +18,7 @@ const buildUiState = (): UiState => ({ inlineDiffs: true, mouseTracking: MOUSE_TRACKING, pasteCollapseLines: 5, + pasteCollapseChars: 2000, sections: {}, showCost: false, showReasoning: false, diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 583f812f424..40120326a87 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -190,8 +190,11 @@ export function useComposerState({ const lineCount = cleanedText.split('\n').length const pasteCollapseLines = getUiState().pasteCollapseLines + const pasteCollapseChars = getUiState().pasteCollapseChars + const linesHit = pasteCollapseLines > 0 && lineCount >= pasteCollapseLines + const charsHit = pasteCollapseChars > 0 && cleanedText.length >= pasteCollapseChars - if (pasteCollapseLines === 0 || lineCount < pasteCollapseLines) { + if (!linesHit && !charsHit) { return { cursor: cursor + cleanedText.length, value: value.slice(0, cursor) + cleanedText + value.slice(cursor) diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 57a3d153d65..f159bbbd17b 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -153,6 +153,17 @@ const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number = return 5 } +const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => { + if (!cfg?.config) return 2000 + const raw = cfg.config.paste_collapse_char_threshold + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw) + if (typeof raw === 'string') { + const n = parseInt(raw, 10) + if (Number.isFinite(n) && n >= 0) return n + } + return 2000 +} + /** Fetch ``config.get full`` and fan the result through ``applyDisplay``. * * Extracted so the mtime-reload path can be exercised by the test @@ -200,6 +211,7 @@ export const applyDisplay = ( inlineDiffs: d.inline_diffs !== false, mouseTracking: normalizeMouseTracking(d), pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg), + pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg), sections: resolveSections(d.sections), showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 356b23dd0d6..4ff1483b236 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -82,7 +82,7 @@ export interface ConfigVoiceConfig { } export interface ConfigFullResponse { - config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig; paste_collapse_threshold?: number } + config?: { display?: ConfigDisplayConfig; voice?: ConfigVoiceConfig; paste_collapse_threshold?: number; paste_collapse_char_threshold?: number } } export interface ConfigMtimeResponse {