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
This commit is contained in:
12
cli.py
12
cli.py
@ -13352,7 +13352,10 @@ class HermesCLI:
|
|||||||
line_count = pasted_text.count('\n')
|
line_count = pasted_text.count('\n')
|
||||||
buf = event.current_buffer
|
buf = event.current_buffer
|
||||||
threshold = self.config.get("paste_collapse_threshold", 5)
|
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_counter[0] += 1
|
||||||
paste_dir = _hermes_home / "pastes"
|
paste_dir = _hermes_home / "pastes"
|
||||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -13521,8 +13524,11 @@ class HermesCLI:
|
|||||||
newlines_added = line_count - _prev_newline_count[0]
|
newlines_added = line_count - _prev_newline_count[0]
|
||||||
_prev_newline_count[0] = line_count
|
_prev_newline_count[0] = line_count
|
||||||
is_paste = chars_added > 1 or newlines_added >= 4
|
is_paste = chars_added > 1 or newlines_added >= 4
|
||||||
threshold = self.config.get("paste_collapse_threshold_fallback", 0)
|
threshold = self.config.get("paste_collapse_threshold_fallback", 5)
|
||||||
if threshold > 0 and line_count >= threshold and is_paste and not text.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(text) >= char_threshold
|
||||||
|
if (lines_hit or chars_hit) and is_paste and not text.startswith('/'):
|
||||||
_paste_counter[0] += 1
|
_paste_counter[0] += 1
|
||||||
paste_dir = _hermes_home / "pastes"
|
paste_dir = _hermes_home / "pastes"
|
||||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@ -1905,13 +1905,25 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
# Paste collapse thresholds (TUI + CLI).
|
# 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).
|
# paste_collapse_threshold (default 5)
|
||||||
# collapse_threshold_fallback: same but for the fallback heuristic used
|
# Bracketed-paste handler. Pastes with this many newlines or more
|
||||||
# by terminals without bracketed paste support (destructive: replaces
|
# collapse to a file reference. Set 0 to disable.
|
||||||
# entire buffer). 0 = disabled.
|
#
|
||||||
|
# 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": 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
|
# Config schema version - bump this when adding new required fields
|
||||||
|
|||||||
@ -106,6 +106,7 @@ export interface UiState {
|
|||||||
inlineDiffs: boolean
|
inlineDiffs: boolean
|
||||||
mouseTracking: MouseTrackingMode
|
mouseTracking: MouseTrackingMode
|
||||||
pasteCollapseLines: number
|
pasteCollapseLines: number
|
||||||
|
pasteCollapseChars: number
|
||||||
|
|
||||||
sections: SectionVisibility
|
sections: SectionVisibility
|
||||||
showCost: boolean
|
showCost: boolean
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const buildUiState = (): UiState => ({
|
|||||||
inlineDiffs: true,
|
inlineDiffs: true,
|
||||||
mouseTracking: MOUSE_TRACKING,
|
mouseTracking: MOUSE_TRACKING,
|
||||||
pasteCollapseLines: 5,
|
pasteCollapseLines: 5,
|
||||||
|
pasteCollapseChars: 2000,
|
||||||
sections: {},
|
sections: {},
|
||||||
showCost: false,
|
showCost: false,
|
||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
|
|||||||
@ -190,8 +190,11 @@ export function useComposerState({
|
|||||||
|
|
||||||
const lineCount = cleanedText.split('\n').length
|
const lineCount = cleanedText.split('\n').length
|
||||||
const pasteCollapseLines = getUiState().pasteCollapseLines
|
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 {
|
return {
|
||||||
cursor: cursor + cleanedText.length,
|
cursor: cursor + cleanedText.length,
|
||||||
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
|
value: value.slice(0, cursor) + cleanedText + value.slice(cursor)
|
||||||
|
|||||||
@ -153,6 +153,17 @@ const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number =
|
|||||||
return 5
|
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``.
|
/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
|
||||||
*
|
*
|
||||||
* Extracted so the mtime-reload path can be exercised by the test
|
* Extracted so the mtime-reload path can be exercised by the test
|
||||||
@ -200,6 +211,7 @@ export const applyDisplay = (
|
|||||||
inlineDiffs: d.inline_diffs !== false,
|
inlineDiffs: d.inline_diffs !== false,
|
||||||
mouseTracking: normalizeMouseTracking(d),
|
mouseTracking: normalizeMouseTracking(d),
|
||||||
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
|
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
|
||||||
|
pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg),
|
||||||
sections: resolveSections(d.sections),
|
sections: resolveSections(d.sections),
|
||||||
showCost: !!d.show_cost,
|
showCost: !!d.show_cost,
|
||||||
showReasoning: !!d.show_reasoning,
|
showReasoning: !!d.show_reasoning,
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export interface ConfigVoiceConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigFullResponse {
|
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 {
|
export interface ConfigMtimeResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user