Compare commits

...
Author SHA1 Message Date
bincxz b3a7dd4882 fix: address Codex review on PR #3175 2026-08-29 11:37:12 +08:00
bincxz afd382473a fix(notes): match parse failures across outer whitespace 2026-08-28 13:56:35 +08:00
netcatty-bot 9992217832 fix: address Codex review on PR #3175 2026-08-28 05:43:16 +00:00
bincxz bb47a32375 fix(notes): guard stale markdown parse fallback 2026-08-28 12:21:54 +08:00
bincxzandClaude 06caab8d28 fix(notes): limit rich retry seed to the stale failed value
Keep the retry markdown override only while the parent prop still contains
the stale body that originally failed MDX import. Once NotesManager's
source-draft debounce catches up, the editor goes back to normal prop
synchronization instead of keeping an old retry seed around.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 20:30:44 +08:00
bincxzandClaude 070a9f0daa fix(notes): retry the rich editor with the latest source draft
Codex review follow-ups for the unrenderable-markdown fallback:

- Retry now seeds the MDX import from the editor's latest accepted source
  draft. NotesManager debounces source drafts (300 ms), so retrying right
  after fixing the markdown could remount the rich editor with the stale
  pre-fix `value` prop and drop straight back into the fallback.
- The read-only preview fallback no longer mutates the note: NoteSourceEditor
  skips its custom Tab insertion, undo/redo handling and imperative
  insertAction when readOnly is set (the DOM readOnly attribute alone did
  not block those paths).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 20:22:40 +08:00
bincxzandClaude 6db5f823ba fix(notes): keep unrenderable markdown visible in the notes editor
The Vault notes rich editor parses note content as MDX. Some valid
CommonMark is not valid MDX — most commonly an unbalanced angle tag such
as `mysql -h <host> -u <user>` written in prose. When mdxeditor fails to
import the markdown it clears the lexical document and only reports the
failure through its onError callback, which netcatty never wired up. The
note then rendered as an empty editor with just the placeholder, while
the content was still intact in the vault (export/copy still worked).

This is exactly the note-view half of #3167: the AI wrote a skill
document into Vault → Notes, the note showed no content, and exporting
it produced a complete markdown file.

The editor now listens to MDXEditor's onError and falls back to the raw
markdown source view with a short notice, so the content is always
visible (and editable) instead of silently blank. Recovery is
deterministic: switching note or editor mode retries the rich import,
and the notice offers an explicit retry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 20:13:29 +08:00
陈大猫 180470a03b Merge pull request #3156 from keroroyuki/fix/3138-strip-zero-width-input-chars
fix: strip zero-width characters from terminal input (#3138)
2026-08-27 16:34:59 +08:00
bincxzandClaude a3380d26e8 fix: base raw-paste splitting on the sanitized payload
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 15:58:21 +08:00
bincxz 5dbede1368 Merge commit '08044ee98' into pr-3156-resolve
# Conflicts:
#	components/terminal/runtime/createXTermRuntime.ts
#	components/terminal/runtime/shiftEnterText.test.ts
#	components/terminal/runtime/terminalImeTextInput.test.ts
2026-08-27 15:43:24 +08:00
陈大猫 715f71f18c Merge pull request #3168 from binaricat/fix/bastion-per-character-input
fix(terminal): send IME commits and short pastes per character
2026-08-27 14:52:18 +08:00
bincxzandClaude d809b6c3fa fix(terminal): send IME commits and short pastes per character
Strict bastion prompts (QAX/奇安信) treat one SSH channel write as a single keystroke and silently drop every multi-character chunk, so an IME commit of two or more hanzi and a Chinese paste both reach the bastion's search prompt as nothing at all. Single characters work, which is why typing them one at a time is the only path that works today.

Split plain multi-character text at the send boundary instead of adding a setting: the write side of handleTerminalInputData now iterates one chunk per Unicode code point, and all bookkeeping (command buffer, script recorder, autocomplete, sudo autofill, broadcast, scroll) still sees the original payload exactly once. IME commits split whenever the committed text has more than one code point; raw (non-bracketed) onData splits up to 32 characters and longer pastes keep the single write. Anything containing an escape sequence - bracketed paste wrappers, CSI-u composition, forwarded Kitty key sequences, cursor reports - is never split, and broadcast peers still receive the original string once.

Each chunk carries the same writeToSession options as the original single write, so the sensitive marker and automation flags are kept per character. Bridge writes stay one channel write per IPC call, which reproduces what Xshell sends on the same bastion (#3077).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 14:35:27 +08:00
陈大猫 65d334ff8d Merge pull request #3165 from binaricat/fix/alternate-screen-history-preview
fix(terminal): scrollable history preview inside the alternate screen
2026-08-27 14:14:36 +08:00
陈大猫 a09a0cb4ec Merge pull request #3164 from binaricat/fix/3103-ime-punctuation-deferral-stuck
fix(terminal): recover the stuck IME punctuation deferral that swallows subsequent input
2026-08-27 14:14:14 +08:00
bincxz 101b686dbd fix(terminal): release the deferred press after an unrelated keyup
When the punctuation keyup was dropped and a previously held key was released
next, the recovery flushed the deferred key (recording a Kitty press) but then
released only the unrelated key, leaving the deferred press forwarded until
focus loss.

Replace the sentinel-rewrite predicate with resolveDeferredKeyupRelease, which
enumerates all three cases: sentinel releases take over the deferred key's
release identity, the deferred key's own release pairs the press directly, and
an unrelated release keeps its identity while the deferred press gets a
synthesized release.
2026-08-27 13:51:58 +08:00
bincxzandClaude 5f67c8b720 fix(terminal): reset the output history cell cursor on clear
Clearing a history object reused by a new session kept the previous
session's cell column, so a leading tab in the fresh output padded to the
wrong tab stop.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 13:35:26 +08:00
bincxzandClaude eb615b9da5 fix(terminal): harden output history control parsing and line budgeting
Follow-ups on the alternate-screen preview transcript:

- Cap the pending escape tail on its introducer, so an oversized control
  string split across chunks is not re-parsed as transcript text.
- Write spans in bounded pieces, committing a full line at the character cap,
  instead of clipping a span and dropping its remainder.
- Track the cursor in terminal cells (not UTF-16 length) so tab stops land
  correctly after wide glyphs.
- Apply erase-in-line (CSI 0K / 2K) to the open line, so a shorter progress
  redraw leaves no stale suffix text behind.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 13:27:56 +08:00
bincxz 4f3ca3ec9d fix(terminal): drop the stale IME deferral on modified keydowns
A modified keydown (Ctrl+C, Alt+...) is a command, not the continuation of the
lost punctuation keystroke. Leaving the deferral armed across it meant the next
ordinary keydown flushed the pending character in front of the user's input,
right after the interrupt or shortcut ran.

Discard the stale deferral when the next keydown carries Ctrl/Alt/Meta; an
active composition still resolves it via insertText.
2026-08-27 13:20:27 +08:00
bincxzandClaude f556c9688f fix(terminal): consume 8-bit control strings and expand tabs in output history
Follow-ups on the alternate-screen preview transcript:

- C1 control-string introducers (DCS/SOS/OSC/PM/APC) were filtered as stray
  control bytes, leaving their payload as transcript text; consume them
  through the BEL / ESC-\ / C1 ST terminator like their ESC-based forms.
- Tabs were kept as-is, so a row's cell width under-counted against how the
  preview pre element renders tab stops and long tabbed lines were clipped
  instead of wrapping. Expand each tab to the next 8-column stop while
  writing, and route tab padding through the same open-line budget as text
  spans.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 13:19:08 +08:00
bincxz 00d1c819cf fix(terminal): pair the stale-flush press with a synthesized release
The keydown recovery for a swallowed punctuation keyup flushes the deferred
key, which records a Kitty press locally and for broadcast peers. Since that
branch only runs when the release will never arrive, the press stayed
forwarded and the TUI saw the key held until focus loss or session reset.

Extract the forwarded-press release pairing into releaseForwardedKittyPress and
call it from the recovery with a release synthesized from the deferred physical
key, so the press/release pair completes in the same keystroke.
2026-08-27 13:15:30 +08:00
bincxzandClaude 8bae9ea584 fix(terminal): bound the open output-history line and parse ESC intermediates
Two robustness gaps in the alternate-screen preview transcript:

- A cursor-addressed redraw (cursor-home CSI stripped, no LF) appended every
  frame to the open line, which the character cap only checked on commit, so
  it grew without limit and every append copied it whole. Commit the open
  line once it reaches the budget and clip each span to the remaining room.
- Escape sequences were assumed to be two bytes, so intermediate bytes leaked
  their final byte into the transcript (ESC ( B from ncurses/reset left a
  stray B). Consume intermediates (0x20-0x2f) through the final byte
  (0x30-0x7e) per ECMA-48.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 13:11:37 +08:00
bincxz 5ad4eaa309 fix(terminal): only rewrite IME sentinel keyups to the deferred key
A real keyup for another held key could land while the punctuation deferral
was still armed (the deferred key's own release was swallowed). That release
was rewritten to the deferred physical key, so in Kitty report-event modes the
held key's forwarded press never got its release and the TUI saw it stuck down
until focus loss or session reset.

Restrict the rewrite to IME sentinel releases (Process/229/Unidentified/
Dead/Compose, mirroring the non-text DOM keys the Kitty encoder already
refuses to send as text). Any real release still ends the stale deferral, so
the #3103 recovery is unchanged.
2026-08-27 13:11:13 +08:00
bincxzandClaude 981807e6ca fix(terminal): keep the history preview usable inside the alternate screen
Plain wheel in screen/vim/codex still forwards arrow keys (xterm's
alternate-scroll), and shift+wheel previewed xterm's normal buffer, which
holds no scrollback once an app owns the alternate buffer - so the preview
clamped to a single screen and looked dead (#2516).

When the alternate buffer is active and the normal buffer has no rows above
the viewport, the preview now renders the session's captured output instead:
a bounded plain-text transcript kept next to the connection log capture,
after the programmatic command rewriter (masked commands stay masked) and
before the replay sanitizer, which drops alternate-screen output on purpose.
Rows are wrapped to the terminal columns with wide-character awareness and
reuse the existing wrap-flag selection joining.

Mouse-reporting apps are untouched: their wheel events still go to the app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 12:13:35 +08:00
bincxz 2f020e6513 fix(terminal): recover stuck IME punctuation deferral blocking input
The CJK IME punctuation deferral (#2833) armed on a punctuation keydown and
only resolved on an insertText remap, an exact-key keyup, or a session reset.
Windows IMEs report Process/keyCode 229 as the keyup of a key they consumed,
or drop the keyup, so the deferred key stayed armed: the deferral then blocked
every keypress, and a second "/" search in vi (or any later keystroke routed
through keypress) never reached the PTY (#3103).

The deferral now ends on any real key release (composing releases excluded, so
an active composition still resolves via insertText) and a stale deferral is
flushed when the next unmodified, non-composing keydown for a different key
arrives. The keypress block is scoped to the deferred key instead of every
keypress, so a stale deferral can no longer swallow unrelated input. A mangled
release pairs its kitty release from the deferred physical key instead of the
Process/229 event.
2026-08-27 12:10:06 +08:00
陈大猫 4733cb77fb Merge pull request #3161 from LaiYueJi/fix/zh-tw-file-name-label 2026-08-26 23:38:16 +08:00
LaiYueJi cab224e642 fix(i18n): correct the zh-TW SFTP file name label
'sftp.fileName' read 檔名稱 and its placeholder 輸入檔名稱, which are both
missing a character — the term is 檔案名稱. Every other locale spells it
out (File name / 文件名称 / Имя файла / Nombre del archivo), and the
sibling folder keys directly above already use 資料夾名稱.

Visible in the SFTP "New file" dialog.
2026-08-26 20:04:00 +08:00
keroroyuki 08044ee987 fix: address codex review — preserve ZWNJ/ZWJ and sanitize before Kitty encoding
Two changes based on Codex bot review feedback on PR #3156:

1. Preserve ZWNJ (U+200C) and ZWJ (U+200D) in terminal input.
   These carry meaning in Persian orthography and emoji sequences;
   PTYs can process the raw bytes, so stripping them could corrupt
   filenames, args, and passwords that legitimately contain them.

2. Sanitize composition text before Kitty encoding.
   When Kitty report-all + associated-text mode is negotiated,
   encodeKittyCompositionText converts U+200B into an escape sequence
   (e.g. ESC[0;;8203u) BEFORE reaching the handleTerminalInputData
   guard. Sanitize the raw text in both composition paths
   (commitImeTextInput remap path and onData handler) so zero-width
   artifacts are stripped before encoding, not after.

Update pattern-matching tests to reflect the new variable names
(sanitizedText / sanitizedData) in the Kitty composition paths.
2026-08-26 18:29:25 +08:00
keroroyuki b27aef1cd8 fix: strip zero-width characters from terminal input to prevent hidden command chars (#3138)
CJK IMEs (notably Microsoft Pinyin / Sogou on Windows) occasionally emit
zero-width Unicode characters (U+200B ZWSP, U+FEFF BOM, etc.) when switching
composition modes. xterm.js sends these to the PTY via onData, and with the
15-graphemes Unicode version they render at width 0 — so the command line
looks normal but contains hidden characters that cause the executed command
to fail.

Add sanitizeTerminalInput() and call it at the top of
handleTerminalInputData so every input path (keyboard, IME composition,
paste, Kitty keyboard, Shift+Enter) strips zero-width formatting chars
before they reach the PTY. If the input was zero-width-only, return early
to skip all downstream processing (command buffer, broadcast, sudo autofill).
2026-08-26 17:46:32 +08:00
陈大猫 340f53722c Merge pull request #3136 from binaricat/automation/nix-release-v1.1.82
Update Nix release metadata for v1.1.82
2026-08-25 11:38:25 +08:00
github-actions[bot] cbd25c3fef Update Nix release metadata for v1.1.82 2026-08-25 03:27:42 +00:00
陈大猫 b21e2a7e4b Merge pull request #3134 from binaricat/codex/fix-ssh-auth-test-fixtures
test: repair stale SSH and SFTP expectations
2026-08-25 11:09:54 +08:00
bincxz 132aef813a test(sftp): align transfer warning expectation
The folder conflict copy was clarified but the transfer-center accessibility test still expected the previous wording. Assert the current destructive warning while preserving the aria-describedby coverage.
2026-08-25 10:57:20 +08:00
bincxz 2ad03f5ad2 test(ssh): use valid private keys in auth retry fixtures
The identity-file safety check correctly rejects the old placeholder strings used by four auth retry tests. Generate a real test key and return a realistic parsed private key so the fixtures exercise the intended retry behavior without weakening validation.
2026-08-25 10:49:54 +08:00
陈大猫 2967a5a189 Merge pull request #3133 from binaricat/codex/note-mode-menu-nowrap
fix(notes): keep mode options on one line
2026-08-25 10:23:48 +08:00
28 changed files with 2211 additions and 77 deletions

No files matched your search

+2
View File
@@ -29,6 +29,8 @@ export const enVaultMessages: Messages = {
'notes.mode.preview': 'Preview',
'notes.title.placeholder': 'Note title',
'notes.editor.placeholder': 'Write Markdown notes here...',
'notes.editor.unrenderableMarkdown': 'This note contains Markdown the rich editor cannot display (for example unbalanced <tag> text). Showing the raw Markdown source instead — the content is intact.',
'notes.editor.retryRichView': 'Retry rich view',
'notes.preview.empty': 'Nothing to preview yet.',
'notes.empty.title': 'No notes yet',
'notes.empty.desc': 'Create Markdown notes for runbooks, reminders, and server handoff details.',
+2
View File
@@ -29,6 +29,8 @@ export const esVaultMessages: Messages = {
'notes.mode.preview': 'Vista previa',
'notes.title.placeholder': 'Título de la nota',
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
'notes.editor.unrenderableMarkdown': 'Esta nota contiene Markdown que el editor enriquecido no puede mostrar (por ejemplo, etiquetas sin cerrar como <tag>). Se muestra el origen Markdown sin cambios: el contenido está intacto.',
'notes.editor.retryRichView': 'Reintentar vista enriquecida',
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
'notes.empty.title': 'Aún no hay notas',
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
+2
View File
@@ -29,6 +29,8 @@ export const ruVaultMessages: Messages = {
'notes.mode.preview': 'Просмотр',
'notes.title.placeholder': 'Заголовок заметки',
'notes.editor.placeholder': 'Пишите Markdown-заметки здесь...',
'notes.editor.unrenderableMarkdown': 'Эта заметка содержит Markdown, который не может отобразить визуальный редактор (например, незакрытые теги вида <tag>). Показан исходный Markdown — содержимое не потеряно.',
'notes.editor.retryRichView': 'Повторить в визуальном режиме',
'notes.preview.empty': 'Пока нечего просматривать.',
'notes.empty.title': 'Заметок пока нет',
'notes.empty.desc': 'Создавайте Markdown-заметки для инструкций, напоминаний и передачи серверов.',
+2
View File
@@ -29,6 +29,8 @@ export const zhCNVaultMessages: Messages = {
'notes.mode.preview': '预览',
'notes.title.placeholder': '笔记标题',
'notes.editor.placeholder': '在这里写 Markdown 笔记...',
'notes.editor.unrenderableMarkdown': '这篇笔记包含富文本编辑器无法显示的 Markdown(例如未闭合的 <tag> 文本),已改为显示原始 Markdown 源码,内容并没有丢失。',
'notes.editor.retryRichView': '重试富文本视图',
'notes.preview.empty': '暂无内容可预览。',
'notes.empty.title': '还没有笔记',
'notes.empty.desc': '可以记录运维手册、临时备忘、服务器交接信息。',
+2 -2
View File
@@ -1032,8 +1032,8 @@ export const zhTWCoreMessages: Messages = {
'sftp.goHome': '返回主目錄',
'sftp.folderName': '資料夾名稱',
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔名稱',
'sftp.fileName.placeholder': '輸入檔名稱',
'sftp.fileName': '檔案名稱',
'sftp.fileName.placeholder': '輸入檔案名稱',
'sftp.prompt.newFolderName': '新增資料夾名稱?',
'sftp.rename.title': '重新命名',
'sftp.rename.newName': '新名稱',
+2
View File
@@ -29,6 +29,8 @@ export const zhTWVaultMessages: Messages = {
'notes.mode.preview': '預覽',
'notes.title.placeholder': '筆記標題',
'notes.editor.placeholder': '在這裡寫 Markdown 筆記...',
'notes.editor.unrenderableMarkdown': '這篇筆記包含富文本編輯器無法顯示的 Markdown(例如未閉合的 <tag> 文字),已改為顯示原始 Markdown 原始碼,內容並沒有遺失。',
'notes.editor.retryRichView': '重試富文字檢視',
'notes.preview.empty': '暫無內容可預覽。',
'notes.empty.title': '還沒有筆記',
'notes.empty.desc': '可以記錄運維手冊、臨時備忘、伺服器交接資訊。',
@@ -110,6 +110,9 @@ test("associates the transfer-center folder warning with every destructive Repla
const describedBy = button.getAttribute("aria-describedby");
assert.ok(describedBy, `${button.textContent} should expose the folder deletion warning`);
const warning = env.document.getElementById(describedBy);
assert.match(warning?.textContent ?? "", /Replace deletes all destination files and sub-folders/);
assert.match(
warning?.textContent ?? "",
/Replace: deletes destination-only content and cannot be undone/,
);
}
});
+8 -1
View File
@@ -137,6 +137,7 @@ import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrit
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
import { getTerminalSelectionForClipboard } from "./terminal/normalizeTerminalSelection";
import { getHistoryPreviewSelectionFromRoot } from "./terminal/runtime/terminalHistoryScrollOverride";
import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalOutputHistory";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import {
createTerminalSessionStarters,
@@ -557,6 +558,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const manualReconnectRequestRef = useRef<() => void>(() => {});
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalOutputHistoryRef = useRef(createTerminalOutputHistoryPreview());
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const commandLogRewriterRef = useRef(createProgrammaticCommandLogRewriter());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
@@ -629,6 +631,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const captureTerminalLogData = useCallback((data: string) => {
const readableCommandData = commandLogRewriterRef.current.append(data);
// The alternate-screen preview reads the raw display stream (before the
// replay sanitizer, which drops alternate-screen output on purpose) but
// after the command rewriter, so masked commands stay masked (#2516).
terminalOutputHistoryRef.current.append(readableCommandData);
const replaySafeData = terminalLogSanitizerRef.current.append(readableCommandData);
if (!replaySafeData) return;
connectionLogBufferRef.current.append(replaySafeData);
@@ -3992,6 +3998,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLineBufferRef,
telnetLocalEchoRef,
onTerminalLogData: captureTerminalLogData,
terminalOutputHistory: terminalOutputHistoryRef.current,
onCwdChange: (cwd: string) => {
pluginAwareOnRuntimeCwdChange(cwd, { source: 'osc7' });
},
@@ -4269,7 +4276,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
+121 -4
View File
@@ -18,7 +18,7 @@ import {
} from "@mdxeditor/editor";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import { ExternalLink } from "lucide-react";
import { AlertTriangle, ExternalLink } from "lucide-react";
import {
$createRangeSelection,
$getNearestNodeFromDOMNode,
@@ -149,6 +149,14 @@ export interface InlineMarkdownEditorProps {
export const NOTE_EDIT_DECORATION_DEBOUNCE_MS = 160;
export const shouldApplyMdxParseFailure = (input: {
currentNoteId?: string;
failedNoteId?: string;
currentMarkdown: string;
failedMarkdown: string;
}): boolean => input.currentNoteId === input.failedNoteId
&& input.currentMarkdown.trim() === input.failedMarkdown.trim();
export const getNoteDecorationMutationDelay = (editorMode: NoteEditorMode): number =>
editorMode === "edit" || editorMode === "live" ? NOTE_EDIT_DECORATION_DEBOUNCE_MS : 0;
@@ -978,6 +986,12 @@ export const InlineMarkdownEditor = React.memo(
return;
}
// Unrenderable-markdown fallback: the raw source editor is mounted.
if (mdxFallbackActiveRef.current) {
fallbackSourceEditorRef.current?.insertAction(action);
return;
}
const container = containerRef.current;
const editable = container?.querySelector("[contenteditable]");
const lexicalEditor = editable ? getNearestEditorFromDOMNode(editable) : null;
@@ -1056,12 +1070,19 @@ export const InlineMarkdownEditor = React.memo(
}
},
focus: () => {
if (mdxFallbackActiveRef.current) {
fallbackSourceEditorRef.current?.focus();
return;
}
editorRef.current?.focus();
},
scrollToHeading: (heading: NoteHeadingItem, headingIndex: number) => {
if (controlledEditorMode === "source") {
return sourceEditorRef?.current?.scrollToLine(heading.line) ?? false;
}
if (mdxFallbackActiveRef.current) {
return fallbackSourceEditorRef.current?.scrollToLine(heading.line) ?? false;
}
const occurrence = extractNoteHeadings(value)
.slice(0, headingIndex)
.filter((item) => item.level === heading.level && item.text === heading.text)
@@ -1083,10 +1104,20 @@ export const InlineMarkdownEditor = React.memo(
const [linkAction, setLinkAction] = useState<LinkActionState | null>(null);
const [acceptedSourceMarkdown, setAcceptedSourceMarkdown] = useState(value);
const [isContentSwapping, setIsContentSwapping] = useState(false);
// MDX (the rich editor's parser) rejects some CommonMark — e.g. unbalanced
// angle tags like `<host>` in prose. MDXEditor then renders an EMPTY document
// and only reports the failure through onError, which made such notes look
// like they had lost their content. Track the failure and fall back to the
// raw markdown source view so the content always stays visible.
const [mdxParseFailure, setMdxParseFailure] = useState<string | null>(null);
const linkActionRef = useRef<LinkActionState | null>(null);
linkActionRef.current = linkAction;
const mouseMoveFrameRef = useRef(0);
const editorMode = controlledEditorMode ?? "edit";
const mdxFallbackActive = mdxParseFailure !== null && editorMode !== "source";
const mdxFallbackActiveRef = useRef(false);
mdxFallbackActiveRef.current = mdxFallbackActive;
const fallbackSourceEditorRef = useRef<NoteSourceEditorHandle | null>(null);
const hostPickerRangeRef = useRef<Range | null>(null);
const hostPickerListRef = useRef<FixedSizeVirtualListHandle>(null);
const hostsRef = useRef(hosts);
@@ -1097,7 +1128,7 @@ export const InlineMarkdownEditor = React.memo(
// buttons. Listens to Lexical selection/update changes in edit/live modes.
useEffect(() => {
if (!onActiveFormatsChange) return;
if (editorMode !== "edit" && editorMode !== "live") {
if (mdxFallbackActive || (editorMode !== "edit" && editorMode !== "live")) {
onActiveFormatsChange(EMPTY_ACTIVE_FORMATS);
return;
}
@@ -1141,7 +1172,7 @@ export const InlineMarkdownEditor = React.memo(
unregisterUpdate();
unregisterSelection();
};
}, [editorMode, onActiveFormatsChange]);
}, [editorMode, mdxFallbackActive, onActiveFormatsChange]);
const setLinkActionIfChanged = useCallback((next: LinkActionState | null) => {
if (linkActionStatesEqual(linkActionRef.current, next)) return;
@@ -1226,6 +1257,10 @@ export const InlineMarkdownEditor = React.memo(
const scheduledNoteId = noteId;
noteIdRef.current = noteId;
pasteRecoveryGenerationRef.current += 1;
// The new note's markdown gets a fresh MDX import; clear a previous
// note's unrenderable-markdown fallback.
setMdxParseFailure(null);
setMdxRetry(null);
// Keep both refs in displayMarkdown space so public-path normalization
// does not look like a divergent local draft.
latestMarkdownRef.current = markdown;
@@ -1322,6 +1357,15 @@ export const InlineMarkdownEditor = React.memo(
latestSourceMarkdownRef.current = value;
syncedSourceMarkdownRef.current = value;
setAcceptedSourceMarkdown(value);
if (displayChanged && mdxFallbackActiveRef.current) {
// A sync/publish update replaced this note's unrenderable markdown while
// the source fallback was active. editorRef.setMarkdown below would hit
// the unmounted MDXEditor, so clear the failure instead: the rich editor
// remounts with the new markdown. If it is still unrenderable, onError
// re-arms the fallback.
setMdxParseFailure(null);
setMdxRetry(null);
}
if (!displayChanged) return;
try {
editorRef.current?.setMarkdown(markdown);
@@ -1371,6 +1415,10 @@ export const InlineMarkdownEditor = React.memo(
useEffect(() => {
setLinkAction(null);
// Mode changes remount the rich editor (key={editorMode}); give its MDX
// import a fresh chance after an unrenderable-markdown fallback.
setMdxParseFailure(null);
setMdxRetry(null);
}, [editorMode]);
const getHostPickerContext = useCallback(() => {
@@ -1546,6 +1594,39 @@ export const InlineMarkdownEditor = React.memo(
onChange(markdown);
}, [onChange]);
// MDXEditor reports markdown it cannot import through onError (the lexical
// document is left empty). Switch to the raw source view so the note content
// is never silently hidden; the user can retry or edit the raw markdown.
// The failure is reported while MDXEditor's realm is still rendering, so a
// synchronous setState here would be dropped — defer it to a timer.
const handleMdxParseError = useCallback((payload: { error: string; source: string }) => {
const failedNoteId = noteIdRef.current;
const failedMarkdown = normalizeNotePublicAssetPaths(payload.source);
window.setTimeout(() => {
if (!shouldApplyMdxParseFailure({
currentNoteId: noteIdRef.current,
failedNoteId,
currentMarkdown: latestMarkdownRef.current,
failedMarkdown,
})) return;
// The same note can receive a newer external value before the deferred
// callback runs. Do not show the old import failure over that content.
setMdxParseFailure((current) => current ?? payload.error);
}, 0);
}, []);
// One-shot markdown for the retry after a fallback: NotesManager debounces
// source drafts, so the `value` prop can still hold the pre-fix body when
// the user retries right after editing in the fallback. Seed that retry
// import from the editor's own latest accepted source instead, but only while
// the parent prop is still the stale body that originally failed.
const [mdxRetry, setMdxRetry] = useState<{ markdown: string; staleValue: string } | null>(null);
const retryRichNoteEditor = useCallback(() => {
setMdxRetry({ markdown: latestSourceMarkdownRef.current, staleValue: value });
setMdxParseFailure(null);
}, [value]);
const insertHostLink = useCallback((host: Host) => {
const link = `[${getHostLinkLabel(host)}](${formatSshDeepLinkForHost(host)})`;
const editor = editorRef.current;
@@ -2093,6 +2174,37 @@ export const InlineMarkdownEditor = React.memo(
noteFontFamily={noteFontFamily}
noteFontSize={noteCodeFontSize || noteFontSize}
/>
) : mdxFallbackActive ? (
<div
data-note-markdown-source-fallback="true"
className="flex min-h-[calc(100vh-15rem)] min-w-0 flex-1 flex-col"
>
<div
data-note-markdown-source-notice="true"
className="flex items-start gap-2 border-b border-border/60 bg-muted/40 px-4 py-2 text-xs text-muted-foreground"
>
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span className="min-w-0 flex-1">{t("notes.editor.unrenderableMarkdown")}</span>
<button
type="button"
data-note-markdown-source-retry="true"
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-foreground underline decoration-dotted hover:bg-secondary"
onClick={retryRichNoteEditor}
>
{t("notes.editor.retryRichView")}
</button>
</div>
<NoteSourceEditor
ref={fallbackSourceEditorRef}
noteId={noteId}
value={noteId !== undefined && noteId !== noteIdRef.current ? value : acceptedSourceMarkdown}
placeholder={placeholder}
onChange={commitSourceMarkdown}
readOnly={editorMode === "preview"}
noteFontFamily={noteFontFamily}
noteFontSize={noteCodeFontSize || noteFontSize}
/>
</div>
) : editorMode === "preview" && !displayMarkdown.trim() ? (
<div className="netcatty-note-preview-empty">
{previewEmptyLabel ?? placeholder}
@@ -2101,7 +2213,11 @@ export const InlineMarkdownEditor = React.memo(
<MDXEditor
key={editorMode}
ref={editorRef}
markdown={displayMarkdown}
markdown={
mdxRetry !== null && value === mdxRetry.staleValue
? normalizeNotePublicAssetPaths(mdxRetry.markdown)
: displayMarkdown
}
placeholder={placeholder}
plugins={plugins}
readOnly={editorMode === "preview"}
@@ -2111,6 +2227,7 @@ export const InlineMarkdownEditor = React.memo(
)}
contentEditableClassName="netcatty-mdx-content"
onChange={commitMarkdown}
onError={handleMdxParseError}
/>
)}
</div>
@@ -0,0 +1,359 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import { runWithAct } from "../test-support/renderReactDom.tsx";
/**
* Regression tests for the notes rich editor: markdown that MDX cannot parse
* (e.g. unbalanced angle tags such as `<host>` in prose) used to render as an
* empty editor, so an AI-written or imported note looked like it had lost its
* content. The editor must fall back to the raw markdown source view instead.
*/
const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [
"# SlurmDB Agent Skill",
"",
"## Overview",
"",
"Run the exporter with mysql -h <host> -u <user> -P 3306.",
"",
"The content after the angle tags must stay visible.",
].join("\n");
const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n");
const setupDom = () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
for (const [key, value] of Object.entries({
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
Element: window.Element,
SVGElement: window.SVGElement,
Node: window.Node,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
CustomEvent: window.CustomEvent,
DOMRect: window.DOMRect,
Event: window.Event,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
ResizeObserver: ResizeObserverStub,
IS_REACT_ACT_ENVIRONMENT: true,
})) {
installGlobal(key, value);
}
return {
window,
cleanup() {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
dom.window.close();
},
};
};
type DomHarness = ReturnType<typeof setupDom>;
type ActiveFormats = {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
code: boolean;
};
type RenderEditorProps = {
value: string;
editorMode?: "edit" | "preview" | "source";
onActiveFormatsChange?: (formats: ActiveFormats) => void;
};
const renderEditor = async (
window: DomHarness["window"],
props: RenderEditorProps,
) => {
const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { InlineMarkdownEditor } = await import("./InlineMarkdownEditor.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const changes: string[] = [];
const render = async (nextProps: RenderEditorProps) => act(async () => {
root.render(
<I18nProvider locale="en">
<InlineMarkdownEditor
noteId="note-1"
value={nextProps.value}
placeholder="Write Markdown notes here..."
editorMode={nextProps.editorMode ?? "edit"}
onChange={(next) => changes.push(next)}
onActiveFormatsChange={nextProps.onActiveFormatsChange}
hosts={[]}
/>
</I18nProvider>,
);
});
await render(props);
// Let the deferred MDX import (and its parse-error reporting) settle.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
return {
rootNode,
changes,
rerender: render,
async unmount() {
await act(async () => {
root.unmount();
});
},
};
};
const querySourceFallback = (rootNode: HTMLElement) =>
rootNode.querySelector<HTMLTextAreaElement>("[data-note-markdown-source-fallback] textarea");
const setTextareaValue = (window: DomHarness["window"], textarea: HTMLTextAreaElement, nextValue: string) => {
const setValue = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
assert.ok(setValue);
setValue.call(textarea, nextValue);
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
};
test("unrenderable markdown stays visible via the raw source fallback", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected the raw markdown fallback to be rendered");
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
const notice = rootNode.querySelector("[data-note-markdown-source-notice]");
assert.ok(notice, "expected an explanatory notice next to the fallback");
// The rich editor must not sit next to the fallback showing a blank page.
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
await unmount();
} finally {
cleanup();
}
});
test("plain markdown still renders in the rich editor", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, { value: PLAIN_NOTE_MARKDOWN });
assert.equal(querySourceFallback(rootNode), null);
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor to stay mounted");
assert.match(editable.textContent || "", /Promote the replica/);
await unmount();
} finally {
cleanup();
}
});
test("unrenderable markdown in preview mode falls back to a read-only source view", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, changes, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
editorMode: "preview",
});
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected the raw markdown fallback in preview mode");
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
assert.equal(fallback.readOnly, true);
// Read-only must also block the custom Tab insertion and history handling.
await runWithAct(async () => {
fallback.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Tab",
bubbles: true,
cancelable: true,
}));
});
assert.deepEqual(changes, [], "read-only fallback must not mutate the note");
await unmount();
} finally {
cleanup();
}
});
test("retry imports the latest fallback draft, not the stale prop value", async () => {
const { window, cleanup } = setupDom();
try {
// The host keeps the original (invalid) `value` prop, like the 300 ms
// draft debounce in NotesManager does while the user edits the fallback.
const { rootNode, changes, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
const fallback = querySourceFallback(rootNode);
assert.ok(fallback);
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace("mysql -h <host> -u <user>", "mysql with host and user");
await runWithAct(async () => {
setTextareaValue(window, fallback, fixedMarkdown);
});
assert.deepEqual(changes, [fixedMarkdown]);
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
assert.ok(retry, "expected a retry action on the fallback notice");
await runWithAct(async () => {
retry.click();
});
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor back after the retry");
assert.match(editable.textContent || "", /The content after the angle tags/);
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
await unmount();
} finally {
cleanup();
}
});
test("retry rebinds active-format listeners after the rich editor remounts", async () => {
const { window, cleanup } = setupDom();
try {
const formatChanges: ActiveFormats[] = [];
const onActiveFormatsChange = (formats: ActiveFormats) => {
formatChanges.push(formats);
};
const { rootNode, changes, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
onActiveFormatsChange,
});
assert.ok(querySourceFallback(rootNode), "expected the raw markdown fallback");
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
const callsWhileFallback = formatChanges.length;
const fallback = querySourceFallback(rootNode);
assert.ok(fallback);
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace(
"mysql -h <host> -u <user>",
"mysql with host and user",
);
await runWithAct(async () => {
setTextareaValue(window, fallback, fixedMarkdown);
});
assert.deepEqual(changes, [fixedMarkdown]);
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
assert.ok(retry, "expected a retry action on the fallback notice");
await runWithAct(async () => {
retry.click();
});
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
assert.ok(editable, "expected the rich editor back after the retry");
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
assert.ok(
formatChanges.length > callsWhileFallback,
"expected onActiveFormatsChange after the rich editor remounted",
);
await unmount();
} finally {
cleanup();
}
});
test("same-note external markdown clears an active fallback", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, rerender, unmount } = await renderEditor(window, {
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
});
assert.ok(querySourceFallback(rootNode));
await rerender({ value: PLAIN_NOTE_MARKDOWN });
await runWithAct(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(querySourceFallback(rootNode), null);
assert.match(rootNode.querySelector<HTMLElement>("[contenteditable]")?.textContent || "", /Promote the replica/);
await unmount();
} finally {
cleanup();
}
});
test("leading and trailing whitespace does not hide an unrenderable note", async () => {
const { window, cleanup } = setupDom();
try {
const { rootNode, unmount } = await renderEditor(window, {
value: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
});
const fallback = querySourceFallback(rootNode);
assert.ok(fallback, "expected padded unrenderable markdown to use the source fallback");
assert.equal(fallback.value, `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`);
await unmount();
} finally {
cleanup();
}
});
test("a stale parse error is ignored when the same note has newer markdown", async () => {
const { shouldApplyMdxParseFailure } = await import("./InlineMarkdownEditor.tsx");
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: PLAIN_NOTE_MARKDOWN,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), false);
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), true);
assert.equal(shouldApplyMdxParseFailure({
currentNoteId: "note-1",
failedNoteId: "note-1",
currentMarkdown: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
}), true);
});
+11 -2
View File
@@ -70,10 +70,12 @@ export interface NoteSourceEditorProps {
className?: string;
noteFontFamily?: string;
noteFontSize?: number;
/** Blocks edits (used when the rich editor cannot render the markdown). */
readOnly?: boolean;
}
export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSourceEditorProps>(
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize }, ref) => {
({ noteId, value, placeholder = "", onChange, className = "", noteFontFamily, noteFontSize, readOnly = false }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const lineNumbersRef = useRef<HTMLDivElement>(null);
const [localValue, setLocalValue] = useState(value);
@@ -147,6 +149,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (readOnly) return;
const nextValue = e.target.value;
if (nextValue === localValue) return;
const nativeInputType = (e.nativeEvent as InputEvent | undefined)?.inputType;
@@ -227,6 +230,10 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = textareaRef.current;
if (!textarea) return;
// Read-only fallback (preview mode): the DOM readOnly attribute blocks
// typing but not our custom Tab/undo handling, which would still mutate
// and persist the note.
if (readOnly) return;
const key = e.key.toLowerCase();
const commandModifier = e.metaKey || e.ctrlKey;
@@ -269,7 +276,7 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
useImperativeHandle(ref, () => ({
insertAction: (action: MarkdownActionType) => {
const textarea = textareaRef.current;
if (!textarea) return;
if (!textarea || readOnly) return;
if (action === "undo" || action === "redo") {
applyHistoryAction(action);
@@ -359,6 +366,8 @@ export const NoteSourceEditor = React.forwardRef<NoteSourceEditorHandle, NoteSou
}}
placeholder={placeholder}
spellCheck={false}
readOnly={readOnly || undefined}
aria-readonly={readOnly || undefined}
style={{
fontFamily: noteFontFamily || undefined,
fontSize: noteFontSize ? `${noteFontSize}px` : undefined,
@@ -954,3 +954,64 @@ test("alternate-screen history preview stays selectable and copyable", async ()
assert.match(source, /HISTORY_PREVIEW_HIDE_EVENT/);
assert.match(source, /document\.addEventListener\("copy", handlePreviewNativeCopy, true\)/);
});
test("alternate-screen history preview falls back to the captured session output", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The fallback only applies while the app owns the alternate buffer and the
// normal buffer has no scrollback above the viewport (#2516).
assert.match(source, /bufferHasPreviewScrollback\(normalBuffer\)/);
assert.match(source, /outputHistory\.getPreviewRowCount\(term\.cols\)/);
assert.match(source, /outputHistory\.getPreviewRows\(\{/);
assert.match(source, /nextOutputHistoryPreviewTop\(/);
});
test("multi-character plain text goes out as per-character writes (#3077)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
// drop multi-character chunks, so IME commits and short raw pastes must be
// sent one character per write instead of one write per commit.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
assert.ok(writeLoopIdx >= 0);
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
assert.match(writeLoop, /for \(const chunk of/);
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive \}\)/);
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
assert.match(
source,
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\)/,
);
// Raw onData (unbracketed paste / committed composition) uses the capped
// raw-paste rule; the composition fallback uses the uncapped IME rule.
assert.match(
source,
/handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\)/,
);
assert.match(
source,
/handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\)/,
);
// Negotiated Kitty paths keep their single write: CSI-u associated text and
// forwarded key sequences must never be split across writes.
const compositionIdx = source.indexOf(
"handleTerminalInputData(encoded, { source: \"kitty\" })",
);
assert.ok(compositionIdx >= 0);
assert.doesNotMatch(source.slice(0, compositionIdx + 60), /handleTerminalInputData\(encoded, \{ perCharacterWrites/);
assert.doesNotMatch(source, /handleTerminalInputData\(sequence, \{ perCharacterWrites/);
// Bookkeeping sees the original string exactly once: the command buffer and
// broadcast keep using the unsplit payload.
const writeSite = source.slice(writeLoopIdx - 600, writeLoopIdx + 320);
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
});
+193 -41
View File
@@ -110,9 +110,19 @@ import {
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
resolveDeferredKeyupRelease,
shouldDiscardStaleDeferredImeTextInput,
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
import {
getTextInputWireChunks,
shouldSplitImeTextInputForWire,
shouldSplitRawPasteInputForWire,
} from "./terminalPerCharacterInput";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
import {
isTerminalFontSizeAction,
@@ -125,7 +135,9 @@ import {
HISTORY_PREVIEW_HIDE_EVENT,
HISTORY_PREVIEW_OVERLAY_ATTR,
HISTORY_PREVIEW_WRAP_ATTR,
bufferHasPreviewScrollback,
encodeHistoryPreviewWrapFlags,
type HistoryPreviewRow,
getHistoryPreviewRows,
getHistoryPreviewSelectionFromRoot,
forcedHistoryScrollLinesForWheel,
@@ -139,6 +151,10 @@ import {
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride";
import {
nextOutputHistoryPreviewTop,
type TerminalOutputHistoryPreview,
} from "./terminalOutputHistory";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
import {
@@ -349,6 +365,11 @@ export type CreateXTermRuntimeContext = {
serialLineBufferRef?: RefObject<string>;
telnetLocalEchoRef?: RefObject<boolean>;
onTerminalLogData?: (data: string) => void;
/**
* Captured session output used as the history preview source while an app
* owns the alternate buffer and the normal buffer has no scrollback (#2516).
*/
terminalOutputHistory?: TerminalOutputHistoryPreview;
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
@@ -997,20 +1018,43 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const showAlternateScreenHistoryPreview = (lines: number) => {
if (term.buffer.active.type !== "alternate") return false;
const normalBuffer = term.buffer.normal;
historyPreviewTop = nextHistoryPreviewTop({
buffer: normalBuffer,
currentTop: historyPreviewTop,
lines,
});
// Inside screen/vim/codex the app owns the alternate buffer, so the normal
// buffer has no rows above the viewport and there is nothing to preview.
// Fall back to the captured session output (#2516). Apps that request mouse
// reporting never get here — xterm hands those wheel events to the app.
const outputHistory = ctx.terminalOutputHistory;
const outputRowCount = outputHistory && !bufferHasPreviewScrollback(normalBuffer)
? outputHistory.getPreviewRowCount(term.cols)
: 0;
let previewRows: HistoryPreviewRow[];
if (outputHistory && outputRowCount > 0) {
historyPreviewTop = nextOutputHistoryPreviewTop({
currentTop: historyPreviewTop,
lines,
rows: term.rows,
totalRows: outputRowCount,
});
previewRows = outputHistory.getPreviewRows({
cols: term.cols,
rows: term.rows,
top: historyPreviewTop,
}).rows;
} else {
historyPreviewTop = nextHistoryPreviewTop({
buffer: normalBuffer,
currentTop: historyPreviewTop,
lines,
});
previewRows = getHistoryPreviewRows({
buffer: normalBuffer,
rows: term.rows,
top: historyPreviewTop,
});
}
const overlay = ensureHistoryPreviewOverlay();
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
const previewRows = getHistoryPreviewRows({
buffer: normalBuffer,
rows: term.rows,
top: historyPreviewTop,
});
overlay.textContent = previewRows.map((row) => row.text).join("\n");
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
return true;
@@ -1055,8 +1099,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
source?: "terminal" | "shift-enter" | "kitty";
/** Skip string broadcast when peers will re-resolve from a key chord. */
skipBroadcast?: boolean;
/**
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
) => {
// Strip zero-width / invisible formatting characters that CJK IMEs
// (notably Microsoft Pinyin / Sogou on Windows) emit when switching
// composition modes. With the 15-graphemes Unicode version these render
// at width 0, so they become hidden characters in the command line and
// cause the executed command to fail (#3138).
data = sanitizeTerminalInput(data);
if (!data) return;
// Clipboard paste / typed password while assist is open must dismiss the
// hint first. Otherwise Enter is still hijacked for confirmFill and can
// append the host session password after the user's pasted secret (#2198).
@@ -1170,7 +1229,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// When backspaceBehavior is configured, remap the Backspace key output
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
ctx.onOutputTriggerUserInputRef?.current?.(outData);
ctx.terminalBackend.writeToSession(id, outData, { sensitive });
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, { sensitive });
}
// Local echo for serial connections only when explicitly enabled
if (inputSource !== "kitty" && ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
@@ -1334,7 +1395,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
handleTerminalInputData(text, { perCharacterWrites: shouldSplitImeTextInputForWire(text) });
if (deferredKittyEvent) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
@@ -1376,16 +1437,22 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Actual IME remap — Kitty associated-text composition when negotiated;
// otherwise send the committed glyph (report-all alone cannot carry it).
const encoded = encodeKittyCompositionText(kittyKeyboardMode, text);
// Sanitize before encoding so zero-width IME artifacts do not become
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
const sanitizedText = sanitizeTerminalInput(text);
if (!sanitizedText) return;
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
handleTerminalInputData(sanitizedText, {
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedText),
});
}
broadcastKittyInput({ kind: "text", text });
broadcastKittyInput({ kind: "text", text: sanitizedText });
};
const flushImeTextInputDeferral = () => {
const fallback = imeTextInputDeferredKey;
@@ -1402,6 +1469,35 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// broadcast targets can still receive paired key events.
imeTextInputDeferredKittyEvent = toKittyKeyboardEvent(event);
};
/**
* Emit the paired release for a forwarded press, locally and to broadcast
* peers. Used by the keyup handler and by the stale-deferral recovery, where
* the IME dropped the release and one must be synthesized from the deferred
* physical key so the TUI does not see the key held until focus loss.
*/
const releaseForwardedKittyPress = (
event: Pick<KittyKeyboardEvent, "code" | "key"> & KittyKeyboardEvent,
): boolean => {
const identity = event.code || event.key;
const forwardedPress = broadcastForwardedKeys.get(identity);
if (forwardedPress) {
broadcastForwardedKeys.delete(identity);
broadcastKittyInput(
{ kind: "key", event },
true,
forwardedPress.targetSessionIds,
);
}
if (!kittyForwardedKeys.delete(identity)) return false;
const sequence = kittyKeyboardProtocolEnabled
? encodeKittyKeyEvent(kittyKeyboardMode, event)
: null;
if (sequence) {
handleTerminalInputData(sequence, { source: "kitty" });
return true;
}
return false;
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
@@ -1448,38 +1544,57 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (e.type === "keyup") {
// insertText for this keystroke has already run when present; flush the
// deferred ASCII key only when the IME did not remap it.
if (imeTextInputDeferredKey !== null && e.key === imeTextInputDeferredKey) {
flushImeTextInputDeferral();
}
const identity = kittyKeyIdentity(e);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
const forwardedPress = broadcastForwardedKeys.get(identity);
if (forwardedPress) {
broadcastForwardedKeys.delete(identity);
broadcastKittyInput(
{ kind: "key", event: toKittyKeyboardEvent(e) },
true,
forwardedPress.targetSessionIds,
// deferred ASCII key when the IME did not remap it. Any real key release
// ends the deferral: Windows IMEs report Process/229 as the release of a
// key they consumed (or drop the keyup), and an exact key match left the
// deferral armed so every later keypress was swallowed (#3103).
let releaseEvent: KeyboardEvent = e;
if (
imeTextInputDeferredKey !== null &&
shouldFlushDeferredImeTextInputOnKeyUp(imeTextInputDeferredKey, e)
) {
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
const releaseMode = resolveDeferredKeyupRelease(
imeTextInputDeferredKey,
deferredKittyEvent?.code ?? null,
e,
);
flushImeTextInputDeferral();
if (deferredKittyEvent && releaseMode === "deferred") {
// The release reported an IME sentinel (Process/229); pair the
// flushed press from the deferred physical key so the kitty
// sequence keeps its key identity.
releaseEvent = {
...deferredKittyEvent,
type: "keyup",
} as unknown as KeyboardEvent;
} else if (deferredKittyEvent && releaseMode === "unrelated") {
// Another held key was released first: this keyup only ends the
// stale deferral, so the flushed press needs its own synthesized
// release while the real keyup keeps its identity.
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
}
}
if (!kittyForwardedKeys.delete(identity)) return true;
const kittyEvent = toKittyKeyboardEvent(e);
const sequence = kittyKeyboardProtocolEnabled
? encodeKittyKeyEvent(kittyKeyboardMode, kittyEvent)
: null;
if (sequence) {
const identity = kittyKeyIdentity(releaseEvent);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
e.preventDefault();
e.stopPropagation();
handleTerminalInputData(sequence, { source: "kitty" });
return false;
}
return true;
}
// Block keypress so xterm cannot re-emit the half-width ASCII char after we
// deferred the matching keydown for IME insertText (#2833).
if (shouldBlockKeyPressForImeTextInput(imeTextInputDeferredKey, e)) {
// deferred the matching keydown for IME insertText (#2833). Scoped to the
// deferred key so a stale deferral cannot swallow unrelated keystrokes.
if (
shouldBlockKeyPressForImeTextInput(
imeTextInputDeferredKey,
e,
imeTextInputDeferredKittyEvent?.keyCode ?? null,
)
) {
return false;
}
@@ -1489,6 +1604,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (handlingKittyBroadcast) return true;
// A deferred punctuation keystroke that outlived its own release means the
// IME swallowed the keyup (Windows reports Process/229). Flush it before
// the next keystroke so the pending ASCII key still reaches the PTY
// instead of wedging the deferral (#3103). A modified keydown is a command
// rather than a continuation, so the lost keystroke is dropped there
// instead of being injected in front of the interrupt or shortcut.
if (imeTextInputDeferredKey !== null) {
if (shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)) {
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
flushImeTextInputDeferral();
if (deferredKittyEvent) {
// The flush emitted the deferred press, but the release it waits for
// is the one the IME dropped — synthesize it so the TUI does not see
// the key held until focus loss.
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
}
} else if (
shouldDiscardStaleDeferredImeTextInput(imeTextInputDeferredKey, e)
) {
clearImeTextInputDeferral();
}
}
if (e.keyCode === 229) {
markKittyCompositionPending(true);
}
@@ -2129,16 +2267,23 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
window.clearTimeout(kittyCompositionClearTimer);
kittyCompositionClearTimer = undefined;
}
const encoded = encodeKittyCompositionText(kittyKeyboardMode, data);
// Sanitize before encoding so zero-width IME artifacts do not become
// Kitty escape sequences that bypass the handleTerminalInputData guard (#3138).
const sanitizedData = sanitizeTerminalInput(data);
if (!sanitizedData) return;
const encoded = encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(data);
// Committed composition text, not a negotiated Kitty sequence.
handleTerminalInputData(sanitizedData, {
perCharacterWrites: shouldSplitImeTextInputForWire(sanitizedData),
});
}
broadcastKittyInput({ kind: "text", text: data });
broadcastKittyInput({ kind: "text", text: sanitizedData });
return;
}
if (broadcastLegacyDataPending) {
@@ -2154,7 +2299,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
broadcastLegacyDataPending = null;
}
handleTerminalInputData(data);
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): short plain text must reach strict bastions per character.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
handleTerminalInputData(sanitizedRawData, {
perCharacterWrites: shouldSplitRawPasteInputForWire(sanitizedRawData),
});
});
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
@@ -146,7 +146,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
assert.match(
source,
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+\},\s+\) => \{/s,
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+[\s\S]*?perCharacterWrites\?: boolean;\s+\},\s+\) => \{/s,
);
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0).
assert.match(
@@ -173,11 +173,11 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/term\.onData\(\(data\) => \{[\s\S]*handleTerminalInputData\(data\);\s+\}\);/,
/term\.onData\(\(data\) => \{[\s\S]*const sanitizedRawData = sanitizeTerminalInput\(data\);[\s\S]*handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\);\s+\}\);/,
);
assert.match(
source,
/const encoded = encodeKittyCompositionText\(kittyKeyboardMode, data\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(data\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: data \}\);/,
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
);
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
assert.match(
@@ -4,6 +4,7 @@ import test from "node:test";
import {
HISTORY_PREVIEW_OVERLAY_ATTR,
HISTORY_PREVIEW_WRAP_ATTR,
bufferHasPreviewScrollback,
encodeHistoryPreviewWrapFlags,
getHistoryPreviewLines,
getHistoryPreviewRows,
@@ -129,6 +130,12 @@ test("alternate-screen history preview reads normal-buffer history", () => {
]);
});
test("only a normal buffer with rows above the viewport offers preview scrollback", () => {
assert.equal(bufferHasPreviewScrollback({ baseY: 12 }), true);
// Alternate-screen hosts leave the normal buffer at a single viewport.
assert.equal(bufferHasPreviewScrollback({ baseY: 0 }), false);
});
test("history preview stays up for pointer selection and copy chords", () => {
const overlay = { contains: (node: unknown) => node === "inside" };
@@ -69,6 +69,14 @@ export const clampHistoryPreviewTop = (top: number, buffer: Pick<BufferLike, "ba
return Math.max(0, Math.min(maxTop, top));
};
/**
* True when the buffer still has rows above the viewport, i.e. something to
* preview. In the alternate screen there is none (screen/vim/codex own that
* buffer), so the preview falls back to the captured session output.
*/
export const bufferHasPreviewScrollback = (buffer: Pick<BufferLike, "baseY">): boolean =>
buffer.baseY > 0;
export const nextHistoryPreviewTop = ({
buffer,
currentTop,
@@ -9,6 +9,10 @@ import {
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
resolveDeferredKeyupRelease,
shouldDiscardStaleDeferredImeTextInput,
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
const runtimeSource = readFileSync(
@@ -63,9 +67,305 @@ test("shouldDeferKeyDownForImeTextInput leaves composition and modified keys alo
});
test("shouldBlockKeyPressForImeTextInput only while a deferral is armed", () => {
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keypress" }), true);
assert.equal(shouldBlockKeyPressForImeTextInput(null, { type: "keypress" }), false);
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keydown" }), false);
assert.equal(
shouldBlockKeyPressForImeTextInput(",", { type: "keypress", key: "," }),
true,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(null, { type: "keypress", key: "," }),
false,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(",", { type: "keydown", key: "," }),
false,
);
});
test("shouldBlockKeyPressForImeTextInput still blocks the deferred keystroke itself", () => {
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "/", keyCode: 191 },
191,
),
true,
);
// Shift+/ reports "?" on keydown, so the deferral arms with "?".
assert.equal(
shouldBlockKeyPressForImeTextInput(
"?",
{ type: "keypress", key: "?", keyCode: 191 },
191,
),
true,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "/", isComposing: true },
191,
),
true,
);
});
test("a stale deferral must not swallow unrelated keypresses (#3103)", () => {
// Uppercase letters are routed through keypress by xterm, so a blanket
// keypress block turned one stale deferral into a terminal that ignored
// everything typed afterwards.
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "X", keyCode: 88 },
191,
),
false,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "a", keyCode: 65 },
191,
),
false,
);
// A matching keyCode keeps the block when an IME rewrites the key label.
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "1", keyCode: 191 },
191,
),
true,
);
});
test("a Windows IME release reporting Process must flush the deferred slash (#3103)", () => {
// Windows + CJK IME in vi: keydown still reports the physical key while the
// IME consumes it, then reports Process/229 on the release and never sends
// an insertText the runtime would treat as a remap.
let deferredKey: string | null = null;
const keydown = { type: "keydown", key: "/", keyCode: 191 };
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
assert.equal(deferredKey, "/");
const release = { type: "keyup", key: "Process", keyCode: 229 };
if (shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, release)) deferredKey = null;
assert.equal(deferredKey, null, "the deferral must not outlive its keystroke");
// Input keeps flowing afterwards.
assert.equal(
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "x", keyCode: 88 }),
false,
);
});
test("a held key released while the deferral is armed keeps its own keyup identity", () => {
// "/" is deferred (its own release was swallowed) and the user still holds
// "a" with the other hand, so the "a" keyup lands first. The stale deferral
// still ends, but that release is a real keyup, not an IME sentinel: it
// keeps its own identity, and the deferred press is released separately.
const deferredKey = "/";
const heldKeyUp = { type: "keyup", key: "a", keyCode: 65, code: "KeyA" };
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, heldKeyUp),
true,
"the stale deferral still ends on a real release",
);
assert.equal(
resolveDeferredKeyupRelease(deferredKey, "Slash", heldKeyUp),
"unrelated",
"an unrelated release must not be rewritten to the deferred key",
);
});
test("only IME sentinel releases take over the deferred key's release identity", () => {
// Windows IMEs report Process/229 (occasionally Unidentified) as the release
// of a key they consumed — those are the only releases that stand in for the
// deferred punctuation key.
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Process", keyCode: 229 }),
"deferred",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Unidentified" }),
"deferred",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "/", keyCode: 191 }),
"own",
"a matched release already encodes from the real event",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", {
type: "keyup",
key: "1",
keyCode: 191,
code: "Slash",
}),
"own",
"a release of the same physical key already pairs the flushed press",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Shift", keyCode: 16 }),
"own",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", {
type: "keyup",
key: "a",
keyCode: 65,
code: "KeyA",
ctrlKey: true,
}),
"own",
);
assert.equal(
resolveDeferredKeyupRelease(null, null, { type: "keyup", key: "Process", keyCode: 229 }),
"own",
);
});
test("a deferral left without release or insertText recovers on the next keystroke (#3103)", () => {
let deferredKey: string | null = null;
const keydown = { type: "keydown", key: "/", keyCode: 191 };
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
assert.equal(deferredKey, "/");
// No insertText and no keyup at all; the user then types a plain letter.
const nextKeyDown = { type: "keydown", key: "a", keyCode: 65 };
if (shouldFlushStaleDeferredImeTextInput(deferredKey, nextKeyDown)) deferredKey = null;
assert.equal(deferredKey, null, "the stale deferral must flush before the new keystroke");
assert.equal(
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "a", keyCode: 65 }),
false,
);
});
test("a modified keydown discards the stale deferral instead of flushing it", () => {
// The IME swallowed the "/" release and the user then interrupts with
// Ctrl+C. Flushing there would inject "/" in front of the interrupt, and
// keeping the deferral armed would inject it before the next character, so
// the lost keystroke is dropped instead.
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
true,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "Tab",
keyCode: 9,
altKey: true,
}),
true,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "d",
keyCode: 229,
ctrlKey: true,
isComposing: true,
}),
false,
"a composition still owns the keystroke and resolves via insertText",
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", { type: "keydown", key: "a", keyCode: 65 }),
false,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput(null, {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
});
test("auto-repeat and modifier keystrokes keep the deferral armed", () => {
// Same-key keydown is auto-repeat: re-arm instead of flushing, so a held key
// does not emit an extra character per repeat.
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "/", keyCode: 191 }),
false,
);
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "Shift", keyCode: 16 }),
false,
);
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", {
type: "keydown",
key: "d",
keyCode: 229,
isComposing: true,
}),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "Shift", keyCode: 16 }),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", {
type: "keyup",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
});
test("an active composition still resolves the deferred keystroke via insertText", () => {
// The IME absorbed the punctuation into a composition: the composing release
// must not flush the ASCII key ahead of the committed glyph.
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", {
type: "keyup",
key: "/",
keyCode: 191,
isComposing: true,
}),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput("/", { inputType: "insertText", data: "、" }),
true,
);
});
test("a matched keyup still flushes the English punctuation fallback (#2833)", () => {
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "/", keyCode: 191 }),
true,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp(null, { type: "keyup", key: "/", keyCode: 191 }),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keydown", key: "/", keyCode: 191 }),
false,
);
});
test("shouldCommitDeferredImeTextInput accepts insertText payloads while deferred", () => {
@@ -100,7 +400,10 @@ test("isUnchangedDeferredImeTextInput detects English punctuation flush", () =>
test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () => {
assert.match(runtimeSource, /shouldDeferKeyDownForImeTextInput\(e\)/);
assert.match(runtimeSource, /armImeTextInputDeferral\(e\)/);
assert.match(runtimeSource, /shouldBlockKeyPressForImeTextInput\(imeTextInputDeferredKey, e\)/);
assert.match(
runtimeSource,
/shouldBlockKeyPressForImeTextInput\(\s*imeTextInputDeferredKey,\s*e,\s*imeTextInputDeferredKittyEvent\?\.keyCode \?\? null,\s*\)/,
);
assert.match(
runtimeSource,
/shouldCommitDeferredImeTextInput\(imeTextInputDeferredKey, event\)/,
@@ -132,7 +435,7 @@ test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () =>
// Unchanged ASCII must encode via Kitty key events, not composition text.
const unchangedIdx = runtimeSource.indexOf("isUnchangedDeferredImeTextInput(deferredKey, text)");
const compositionIdx = runtimeSource.indexOf(
"encodeKittyCompositionText(kittyKeyboardMode, text)",
"encodeKittyCompositionText(kittyKeyboardMode, sanitizedText)",
);
assert.ok(unchangedIdx >= 0 && compositionIdx > unchangedIdx);
// Even when the source writes the literal glyph, broadcast peers still get
@@ -155,12 +458,69 @@ test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () =>
assert.ok(unchangedFallbackIdx > unchangedIdx);
assert.match(
runtimeSource.slice(unchangedFallbackIdx, unchangedFallbackIdx + 1800),
/handleTerminalInputData\(text\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
);
// Remap path must fall back to literal text when composition encoding is null
// (report-all without associated text).
assert.match(
runtimeSource.slice(compositionIdx, compositionIdx + 420),
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(text\);/,
runtimeSource.slice(compositionIdx, compositionIdx + 560),
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedText, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedText\),?\s*\}\);/,
);
});
test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", () => {
// Any real key release ends the deferral, not just an exact key match.
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
assert.ok(keyupIdx >= 0);
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2000);
assert.match(
keyupSlice,
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
);
assert.match(keyupSlice, /flushImeTextInputDeferral\(\);/);
// Only an IME sentinel release (Process/229/Unidentified) takes over the
// deferred key's release identity; an unrelated keyup keeps its own identity
// and the flushed press is released separately.
assert.match(
keyupSlice,
/resolveDeferredKeyupRelease\(\s*imeTextInputDeferredKey,\s*deferredKittyEvent\?\.code \?\? null,\s*e,?\s*\)/,
);
assert.match(keyupSlice, /releaseMode === "deferred"/);
assert.match(keyupSlice, /releaseMode === "unrelated"/);
assert.match(
keyupSlice,
/\.\.\.deferredKittyEvent,\s*type: "keyup",/,
);
const kittyReleaseIdx = keyupSlice.indexOf("toKittyKeyboardEvent(releaseEvent)");
assert.ok(kittyReleaseIdx > keyupSlice.indexOf("flushImeTextInputDeferral();"));
// The stale flush runs on keydown, after the broadcast guard and before the
// composition handling, so a wedged deferral cannot survive a new keystroke.
const staleIdx = runtimeSource.indexOf(
"shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)",
);
assert.ok(staleIdx > keyupIdx);
const keydownGuardIdx = runtimeSource.indexOf("if (handlingKittyBroadcast) return true;", keyupIdx);
const keyCode229Idx = runtimeSource.indexOf("if (e.keyCode === 229) {", keyupIdx);
assert.ok(
keydownGuardIdx > keyupIdx &&
staleIdx > keydownGuardIdx &&
keyCode229Idx > staleIdx,
);
assert.match(
runtimeSource.slice(staleIdx - 400, staleIdx + 200),
/flushImeTextInputDeferral\(\);/,
);
// The recovery runs because the deferred key's release will never arrive, so
// the press emitted by the flush must be paired with a synthesized release
// instead of staying forwarded until focus loss.
const recoverySlice = runtimeSource.slice(staleIdx, staleIdx + 500);
assert.match(recoverySlice, /deferredKittyEvent = imeTextInputDeferredKittyEvent/);
assert.match(recoverySlice, /releaseForwardedKittyPress\(\s*\{\s*\.\.\.deferredKittyEvent,\s*type: "keyup",?\s*\}\s*\)/);
// A modified keydown (Ctrl+C, Alt+…) drops the lost keystroke instead of
// injecting it in front of the interrupt or shortcut.
assert.match(
runtimeSource.slice(staleIdx, staleIdx + 700),
/shouldDiscardStaleDeferredImeTextInput\(imeTextInputDeferredKey, e\)/,
);
});
@@ -8,11 +8,18 @@
* Defer those keydowns to the following insertText. If no remap arrives before
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
* isComposing) stays on xterm's CompositionHelper path.
*
* The deferral must never outlive the keystroke it was armed for: Windows IMEs
* report `Process` / keyCode 229 as the keyup of a key they consumed (or drop
* the keyup), so a deferral flushed only on an exact key match stayed armed and
* blocked typed input from then on (#3103). Any real key release, and any later
* unrelated keydown, now ends the deferral.
*/
export type ImeTextInputKeyEvent = {
type?: string;
key: string;
code?: string;
keyCode?: number;
altKey?: boolean;
ctrlKey?: boolean;
@@ -38,11 +45,124 @@ export function shouldDeferKeyDownForImeTextInput(
return isAsciiPunctuationKey(event.key);
}
/** Key releases that carry no keystroke of their own. */
const MODIFIER_ONLY_KEY_RE =
/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Hyper|Super|Fn|FnLock|Symbol|SymbolLock)$/;
export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEY_RE.test(key);
}
/**
* DOM keys that stand in for a key the IME consumed, mirroring the non-text
* DOM keys the Kitty encoder already refuses to send as text.
*/
const IME_SENTINEL_KEYS = new Set(["Dead", "Process", "Unidentified", "Compose"]);
export function isImeSentinelKeyUp(event: ImeTextInputKeyEvent): boolean {
if (event.type !== undefined && event.type !== "keyup") return false;
return event.keyCode === 229 || IME_SENTINEL_KEYS.has(event.key);
}
/**
* A deferred punctuation keystroke is over once any real key release arrives.
* The IME remap (insertText) is dispatched before keyup, so a release means the
* IME did not remap the key and the ASCII character must be flushed.
*
* The release key cannot be matched exactly: Windows IMEs report `Process` /
* keyCode 229 as the release of a key they consumed, and some drop the release
* entirely. Requiring an exact key match left the deferral armed, and the
* armed deferral then blocked input (#3103). Composing releases are excluded —
* an active composition still owns the keystroke and resolves it via
* insertText.
*/
export function shouldFlushDeferredImeTextInputOnKeyUp(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keyup") return false;
if (event.isComposing === true) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return !isModifierOnlyKey(event.key);
}
/**
* A deferral that outlived its own keystroke is stale — the IME swallowed the
* release. Flush it when a new, unmodified, non-composing keydown for a
* different key arrives so the pending ASCII character still reaches the PTY.
* A same-key keydown is auto-repeat (or a second IME attempt) and keeps
* re-arming instead.
*/
export function shouldFlushStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (isModifierOnlyKey(event.key)) return false;
return event.key !== deferredKey;
}
/**
* A modified keydown is a command (Ctrl+C, Alt+…), not the continuation of the
* lost punctuation keystroke: flushing there would inject the ASCII character
* in front of the interrupt or shortcut. Drop the stale deferral instead — the
* keystroke was already lost to the IME.
*/
export function shouldDiscardStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
return Boolean(event.altKey || event.ctrlKey || event.metaKey);
}
/**
* How the keyup that ends a deferral relates to the deferred key.
* - `deferred`: an IME sentinel stood in for the deferred key, so the paired
* release must be encoded from the deferred physical key.
* - `own`: the release belongs to the deferred key itself (matched key or
* physical code), so it already pairs the press the flush emitted.
* - `unrelated`: another held key was released; it keeps its own identity and
* the deferred press needs a separate synthesized release.
*/
export type DeferredKeyupReleaseMode = "deferred" | "own" | "unrelated";
export function resolveDeferredKeyupRelease(
deferredKey: string | null | undefined,
deferredCode: string | null | undefined,
event: ImeTextInputKeyEvent,
): DeferredKeyupReleaseMode {
if (!deferredKey) return "own";
if (event.type !== undefined && event.type !== "keyup") return "own";
if (event.altKey || event.ctrlKey || event.metaKey) return "own";
if (isModifierOnlyKey(event.key)) return "own";
if (event.key === deferredKey) return "own";
if (event.code && deferredCode && event.code === deferredCode) return "own";
if (isImeSentinelKeyUp(event)) return "deferred";
return "unrelated";
}
export function shouldBlockKeyPressForImeTextInput(
deferredKey: string | null | undefined,
event: Pick<ImeTextInputKeyEvent, "type">,
event: ImeTextInputKeyEvent,
deferredKeyCode?: number | null,
): boolean {
return Boolean(deferredKey) && event.type === "keypress";
if (!deferredKey || event.type !== "keypress") return false;
// Composition keypresses carry no committable character on this path.
if (event.isComposing === true || event.keyCode === 229) return true;
// Only the deferred keystroke itself is suppressed. Blocking every keypress
// while armed turned one stale deferral into a terminal that ignored all
// typed input (#3103).
return (
event.key === deferredKey ||
(deferredKeyCode != null && event.keyCode === deferredKeyCode)
);
}
export function shouldCommitDeferredImeTextInput(
@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
test("sanitizeTerminalInput strips zero-width space (U+200B)", () => {
assert.equal(sanitizeTerminalInput("ls\u200b"), "ls");
assert.equal(sanitizeTerminalInput("\u200bls"), "ls");
assert.equal(sanitizeTerminalInput("l\u200bs"), "ls");
});
test("sanitizeTerminalInput strips BOM / ZWNBSP (U+FEFF)", () => {
assert.equal(sanitizeTerminalInput("\ufeffls"), "ls");
assert.equal(sanitizeTerminalInput("ls\ufeff"), "ls");
});
test("sanitizeTerminalInput strips soft hyphen (U+00AD)", () => {
assert.equal(sanitizeTerminalInput("ls\u00ad"), "ls");
});
test("sanitizeTerminalInput preserves ZWNJ (U+200C) and ZWJ (U+200D)", () => {
// ZWNJ is meaningful in Persian orthography; ZWJ joins emoji sequences.
// PTYs / remote programs can process these raw bytes, so they must not be
// stripped — otherwise filenames, args, or passwords containing them break.
assert.equal(sanitizeTerminalInput("a\u200cb"), "a\u200cb");
assert.equal(sanitizeTerminalInput("a\u200db"), "a\u200db");
// Emoji with ZWJ (👨‍💻 = man + ZWJ + laptop) is preserved
assert.equal(sanitizeTerminalInput("👨\u200d💻"), "👨\u200d💻");
});
test("sanitizeTerminalInput strips directional marks (U+200E, U+200F)", () => {
assert.equal(sanitizeTerminalInput("\u200els\u200f"), "ls");
});
test("sanitizeTerminalInput strips word joiner and invisible operators (U+2060-2064)", () => {
assert.equal(sanitizeTerminalInput("ls\u2060"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2061"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2062"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2063"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2064"), "ls");
});
test("sanitizeTerminalInput returns empty string for zero-width-only input", () => {
assert.equal(sanitizeTerminalInput("\u200b"), "");
assert.equal(sanitizeTerminalInput("\u200b\ufeff\u2060"), "");
});
test("sanitizeTerminalInput does not strip ZWNJ/ZWJ-only input", () => {
assert.equal(sanitizeTerminalInput("\u200c"), "\u200c");
assert.equal(sanitizeTerminalInput("\u200d"), "\u200d");
assert.equal(sanitizeTerminalInput("\u200c\u200d"), "\u200c\u200d");
});
test("sanitizeTerminalInput preserves regular ASCII and control characters", () => {
assert.equal(sanitizeTerminalInput("ls\r"), "ls\r");
assert.equal(sanitizeTerminalInput("\r"), "\r");
assert.equal(sanitizeTerminalInput("\n"), "\n");
assert.equal(sanitizeTerminalInput("\u0003"), "\u0003"); // Ctrl+C
assert.equal(sanitizeTerminalInput("\u007f"), "\u007f"); // DEL
});
test("sanitizeTerminalInput preserves CJK and emoji characters", () => {
assert.equal(sanitizeTerminalInput("你好"), "你好");
assert.equal(sanitizeTerminalInput("😀"), "😀");
// Full-width punctuation (common CJK IME output) is preserved
assert.equal(sanitizeTerminalInput(",。!?"), ",。!?");
});
test("sanitizeTerminalInput preserves Kitty escape sequences", () => {
const kittySeq = "\u001b[0;;97:97u";
assert.equal(sanitizeTerminalInput(kittySeq), kittySeq);
});
test("sanitizeTerminalInput handles empty and falsy input", () => {
assert.equal(sanitizeTerminalInput(""), "");
});
test("sanitizeTerminalInput strips multiple interspersed zero-width characters", () => {
assert.equal(sanitizeTerminalInput("\u200bl\u200bs\u200b \u200b-l\u200ba\u200b"), "ls -la");
});
test("sanitizeTerminalInput is stable across repeated calls", () => {
const input = "ls\u200b\r";
const first = sanitizeTerminalInput(input);
const second = sanitizeTerminalInput(first);
assert.equal(first, second);
assert.equal(first, "ls\r");
});
@@ -0,0 +1,39 @@
/**
* Strip zero-width / invisible Unicode formatting characters from terminal
* input before it reaches the PTY.
*
* CJK IMEs (notably Microsoft Pinyin / Sogou on Windows) occasionally emit
* zero-width characters when switching composition modes. xterm.js sends
* these to the PTY via `onData`, and with the `15-graphemes` Unicode
* version they render at width 0 — so the command line looks normal but
* contains hidden characters that cause the executed command to fail (#3138).
*
* ZWNJ (U+200C) and ZWJ (U+200D) are intentionally preserved: they carry
* meaning in Persian orthography and emoji sequences, and PTYs / remote
* programs can process those raw bytes even though they occupy no display
* width. Stripping them would corrupt command arguments, filenames, and
* passwords that legitimately contain them.
*/
// U+00AD SOFT HYPHEN
// U+200B ZERO WIDTH SPACE
// U+200E LEFT-TO-RIGHT MARK
// U+200F RIGHT-TO-LEFT MARK
// U+2060 WORD JOINER
// U+2061 FUNCTION APPLICATION
// U+2062 INVISIBLE TIMES
// U+2063 INVISIBLE SEPARATOR
// U+2064 INVISIBLE PLUS
// U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM
//
// U+200C (ZWNJ) and U+200D (ZWJ) are intentionally excluded.
const ZERO_WIDTH_INPUT_RE = /[\u00ad\u200b\u200e-\u200f\u2060-\u2064\ufeff]/g;
/**
* Remove zero-width / invisible formatting characters from terminal input.
* Returns an empty string when the input consisted solely of such characters.
*/
export function sanitizeTerminalInput(data: string): string {
if (!data) return data;
return data.replace(ZERO_WIDTH_INPUT_RE, "");
}
@@ -38,7 +38,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/writeToSession\(id, outData, \{ sensitive \}\)/u,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive \}\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -0,0 +1,236 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
createTerminalOutputHistoryPreview,
nextOutputHistoryPreviewTop,
stripTerminalDisplayToPlainText,
wrapOutputHistoryLineToRows,
} from "./terminalOutputHistory.ts";
test("display chunks reduce to plain transcript text", () => {
assert.deepEqual(
stripTerminalDisplayToPlainText("\x1b[32mhello\x1b[0m world\r\n"),
{ text: "hello world\r\n", pending: "" },
);
assert.equal(
stripTerminalDisplayToPlainText("\x1b]0;title\x07tail").text,
"tail",
);
assert.equal(
stripTerminalDisplayToPlainText("\x1b]0;title\x1b\\tail").text,
"tail",
);
assert.equal(stripTerminalDisplayToPlainText("a\x07b\x00c").text, "abc");
});
test("escape sequences split across chunks are not leaked into the transcript", () => {
const first = stripTerminalDisplayToPlainText("ok\x1b[3");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x1b[3");
const second = stripTerminalDisplayToPlainText("1mred", first.pending);
assert.equal(second.text, "red");
assert.equal(second.pending, "");
});
test("escape sequences with intermediate bytes are consumed through their final byte", () => {
// ESC ( B designates G0 (ncurses / terminal reset emit it constantly).
assert.equal(stripTerminalDisplayToPlainText("\x1b(Bplain").text, "plain");
// ESC # 8 is DECALN.
assert.equal(stripTerminalDisplayToPlainText("\x1b#8plain").text, "plain");
// Two-byte escapes without intermediates keep working.
assert.equal(stripTerminalDisplayToPlainText("\x1bMplain").text, "plain");
const first = stripTerminalDisplayToPlainText("ok\x1b(");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x1b(");
assert.equal(stripTerminalDisplayToPlainText("Btail", first.pending).text, "tail");
});
test("a cursor-addressed frame without newlines stays inside the character budget", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
for (let frame = 0; frame < 200; frame += 1) {
history.append(`\x1b[Hframe ${frame} ${"x".repeat(80)}`);
}
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 64, `unbounded open line: ${transcript.length}`);
// The newest frame still lands in the retained tail.
assert.ok(transcript.includes("frame 199"), transcript.slice(-200));
});
test("8-bit control strings are consumed through their terminator", () => {
// C1 OSC (0x9d) with C1 ST (0x9c) must not leak its payload.
assert.equal(stripTerminalDisplayToPlainText("\x9d0;SECRET\x9ctail").text, "tail");
// DCS (0x90), SOS (0x98), PM (0x9e) and APC (0x9f) use the same terminator.
assert.equal(stripTerminalDisplayToPlainText("\x90payload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x98payload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x9epayload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x9fpayload\x9ctail").text, "tail");
const first = stripTerminalDisplayToPlainText("ok\x9d0;title");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x9d0;title");
assert.equal(stripTerminalDisplayToPlainText("\x9ctail", first.pending).text, "tail");
});
test("tabs expand to terminal tab stops so preview rows wrap as they render", () => {
const history = createTerminalOutputHistoryPreview();
history.append("a\tb\n");
history.append("abc\td\n");
assert.deepEqual([...history.getLines()], ["a b", "abc d"]);
});
test("tab stops advance by cell columns, not characters", () => {
const history = createTerminalOutputHistoryPreview();
history.append("中\tb\n");
assert.deepEqual([...history.getLines()], ["中 b"]);
});
test("an oversized control string split across chunks leaks no payload", () => {
const payload = "y".repeat(5000);
const first = stripTerminalDisplayToPlainText(`ok\x1b]52;c;${payload}`);
assert.equal(first.text, "ok");
assert.equal(first.pending.length, 4096);
assert.equal(first.pending.startsWith("\x1b]"), true);
const second = stripTerminalDisplayToPlainText("\x07tail", first.pending);
assert.equal(second.text, "tail");
});
test("a span longer than the remaining budget continues into the next line", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 16 });
history.append("aaaaaaaaaaaaaaaaTTTTTTTT\n");
assert.ok([...history.getLines()].join("").includes("TTTTTTTT"));
});
test("erase-in-line after a carriage return drops the stale suffix", () => {
const history = createTerminalOutputHistoryPreview();
history.append("downloading 100%\rdownloading 5%\x1b[K\n");
assert.deepEqual([...history.getLines()], ["downloading 5%"]);
history.clear();
history.append("keep\r\x1b[2Knew\n");
assert.deepEqual([...history.getLines()], ["new"]);
});
test("clear resets the tab stop column with the rest of the line state", () => {
const history = createTerminalOutputHistoryPreview();
history.append("abc");
history.clear();
history.append("\tb\n");
assert.deepEqual([...history.getLines()], [" b"]);
});
test("bare carriage returns overwrite the line they restart", () => {
const history = createTerminalOutputHistoryPreview();
history.append("downloading 10%\r");
history.append("downloading 55%\r");
history.append("downloading 100%\r\n");
history.append("done\n");
assert.deepEqual([...history.getLines()], ["downloading 100%", "done"]);
});
test("history keeps a bounded tail of lines", () => {
const history = createTerminalOutputHistoryPreview({ maxLines: 3 });
for (let index = 0; index < 6; index += 1) history.append(`line ${index}\n`);
assert.deepEqual(
[...history.getLines()],
["line 3", "line 4", "line 5"],
);
});
test("preview rows wrap long lines and flag the continuation rows", () => {
const history = createTerminalOutputHistoryPreview();
history.append("ok\n");
history.append("abcdefgh\n");
const window = history.getPreviewRows({ cols: 4, rows: 3, top: 0 });
assert.equal(window.totalRows, 3);
assert.deepEqual(
window.rows.map((row) => row.text),
["ok", "abcd", "efgh"],
);
assert.deepEqual(
window.rows.map((row) => row.isWrapped),
[false, false, true],
);
});
test("preview windows clamp to the retained rows and pad short output", () => {
const history = createTerminalOutputHistoryPreview();
history.append("one\ntwo\n");
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 2, top: 99 }).rows.map((row) => row.text),
["one", "two"],
);
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 4, top: 0 }).rows.map((row) => row.text),
["one", "two", "", ""],
);
});
test("preview rows keep wide characters intact at a column boundary", () => {
// Four columns hold two CJK cells per row; the glyphs are never split.
assert.deepEqual(wrapOutputHistoryLineToRows("中文中文中文", 4), ["中文", "中文", "中文"]);
const history = createTerminalOutputHistoryPreview();
history.append("中文中文中文\n");
assert.deepEqual(
history.getPreviewRows({ cols: 4, rows: 3, top: 0 }).rows.map((row) => row.text),
["中文", "中文", "中文"],
);
});
test("wheel steps walk the preview from the newest row upwards", () => {
const history = createTerminalOutputHistoryPreview();
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
const totalRows = history.getPreviewRows({ cols: 20, rows: 2, top: 0 }).totalRows;
assert.equal(totalRows, 6);
const bottom = nextOutputHistoryPreviewTop({
currentTop: null,
lines: 0,
rows: 2,
totalRows,
});
assert.equal(bottom, 4);
assert.deepEqual(
history.getPreviewRows({ cols: 20, rows: 2, top: bottom }).rows.map((row) => row.text),
["row 4", "row 5"],
);
const up = nextOutputHistoryPreviewTop({ currentTop: bottom, lines: -3, rows: 2, totalRows });
assert.equal(up, 1);
const top = nextOutputHistoryPreviewTop({ currentTop: up, lines: -30, rows: 2, totalRows });
assert.equal(top, 0);
});
test("clear drops retained transcript and pending escapes", () => {
const history = createTerminalOutputHistoryPreview();
history.append(`keep\n`);
history.clear();
history.append("\x1b[3");
assert.deepEqual([...history.getLines()], []);
assert.equal(
history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).totalRows,
0,
);
});
test("default retention bounds the preview history", () => {
assert.equal(DEFAULT_OUTPUT_HISTORY_MAX_LINES > 0, true);
const history = createTerminalOutputHistoryPreview({ maxLines: 2, maxChars: 6 });
history.append("aaaaaaaa\n");
history.append("bbbbbbbb\n");
history.append("cccccccc\n");
// Retained lines plus the open line stay within twice the character budget,
// and the newest text survives.
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 6, transcript);
assert.ok(transcript.includes("cc"), transcript);
});
@@ -0,0 +1,428 @@
import {
sliceStringByCellColumns,
stringCellWidth,
} from "../../terminal/autocomplete/terminalStringCellWidth";
import type { HistoryPreviewRow } from "./terminalHistoryScrollOverride";
/**
* Plain-text transcript of what a session printed, used as the alternate-screen
* history preview data source (#2516).
*
* Inside screen/vim/codex the app owns the alternate buffer, so xterm's normal
* buffer has no scrollback and the preview has nothing to show. The output
* stream is the only usable source, so every display chunk fed to the log
* capture is reduced to plain lines here: escape sequences are dropped, a bare
* `\r` overwrites the line it restarts (progress bars/spinners stay one line),
* and the retained tail is bounded.
*
* Feed it the chunk *after* the programmatic command rewriter (masked commands
* never reach the preview) but *before* the replay-safe sanitizer, which drops
* alternate-screen output on purpose.
*/
const ESC = "\x1b";
const BEL = "\x07";
const C1_CSI = "\x9b";
const C1_ST = "\x9c";
// Cap on a partially received escape sequence, mirroring the replay sanitizer.
const MAX_PENDING_ESCAPE_CHARS = 4096;
export const DEFAULT_OUTPUT_HISTORY_MAX_LINES = 2000;
export const DEFAULT_OUTPUT_HISTORY_MAX_CHARS = 200_000;
/** xterm's default tab stop interval, used when expanding tabs for the preview. */
const TERMINAL_TAB_STOP_COLUMNS = 8;
// 8-bit (C1) control string introducers: DCS, SOS, OSC, PM, APC. Their
// payloads must be consumed with them, not left as transcript text.
const isC1ControlStringIntroducer = (ch: string): boolean =>
ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f";
const isEscapeIntroducer = (ch: string): boolean =>
ch === ESC || ch === C1_CSI || isC1ControlStringIntroducer(ch);
const findEscapeIntroducer = (input: string, from: number): number => {
for (let i = from; i < input.length; i += 1) {
if (isEscapeIntroducer(input[i])) return i;
}
return -1;
};
const consumeCsiBody = (input: string, from: number): number | null => {
for (let i = from; i < input.length; i += 1) {
// Parameter/intermediate bytes are 0x20-0x3f; the final byte is 0x40-0x7e.
if (input[i] >= "@" && input[i] <= "~") return i + 1;
}
return null;
};
const consumeControlString = (input: string, from: number): number | null => {
for (let i = from; i < input.length; i += 1) {
const ch = input[i];
if (ch === BEL) return i + 1;
if (ch === ESC && input[i + 1] === "\\") return i + 2;
if (ch === C1_ST) return i + 1;
}
return null;
};
const isControlStringIntroducer = (ch: string): boolean =>
ch === "]" || ch === "P" || ch === "^" || ch === "_" || ch === "X";
/** Length of the introducer bytes of the escape sequence starting at `start`. */
const escapeIntroducerLength = (input: string, start: number): number => {
if (input[start] !== ESC) return 1;
const second = input[start + 1];
return second === "[" || isControlStringIntroducer(second) ? 2 : 1;
};
/** Marks an erase-in-line the transcript must apply (kept in stripper output). */
const ERASE_TO_END_OF_LINE = "\x1f";
/**
* The erase-in-line sequences progress lines use (`\r` + text + `\x1b[K`) must
* be applied to the transcript, or the stale suffix of a shorter redraw shows
* up as text. Returns the marker for the variants the transcript can apply.
*/
const eraseInLineMarkerFor = (input: string, start: number, end: number): string | null => {
if (input[end - 1] !== "K") return null;
const introducerLength = escapeIntroducerLength(input, start);
const mode = input.slice(start + introducerLength, end - 1).split(";")[0];
// Erase from start to cursor (1) has no transcript equivalent; skip it.
if (mode === "1") return null;
return ERASE_TO_END_OF_LINE;
};
// ECMA-48 nF sequences: zero or more intermediates (0x20-0x2f) then one final
// byte (0x30-0x7e), e.g. `ESC ( B` (charset) or `ESC # 8` (DECALN).
const isEscapeIntermediateByte = (ch: string): boolean => ch >= " " && ch <= "/";
const isEscapeFinalByte = (ch: string): boolean => ch >= "0" && ch <= "~";
/** Index past the escape sequence at `start`, or null while it is incomplete. */
const consumeEscapeSequence = (input: string, start: number): number | null => {
const introducer = input[start];
if (introducer === C1_CSI) return consumeCsiBody(input, start + 1);
if (isC1ControlStringIntroducer(introducer)) return consumeControlString(input, start + 1);
const second = input[start + 1];
if (second === undefined) return null;
if (second === "[") return consumeCsiBody(input, start + 2);
if (isControlStringIntroducer(second)) return consumeControlString(input, start + 2);
let end = start + 1;
while (end < input.length && isEscapeIntermediateByte(input[end])) end += 1;
if (end >= input.length) return null;
// No final byte: leave the trailing byte to the transcript filters, which
// drop control characters, instead of eating a meaningful one.
if (!isEscapeFinalByte(input[end])) return end;
return end + 1;
};
/**
* Keep the line-editing controls the transcript needs and printable text; drop
* every other control character (BEL, SO/SI, NUL, stray C1 bytes, ...).
*/
const keepTranscriptChars = (span: string): string => {
// Fast path: most spans are plain text with nothing to filter.
let needsFiltering = false;
for (let i = 0; i < span.length; i += 1) {
const code = span.charCodeAt(i);
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
needsFiltering = true;
break;
}
}
if (!needsFiltering) return span;
let kept = "";
for (let i = 0; i < span.length; i += 1) {
const code = span.charCodeAt(i);
if (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f)) {
kept += span[i];
} else if (code === 8 || code === 9 || code === 10 || code === 13 || code === 31) {
kept += span[i];
}
}
return kept;
};
/**
* Reduce a display chunk to plain text: escape sequences are removed while
* `\n` / `\r` / `\b` / `\t` and the erase-in-line marker survive so the history
* can track line edits.
*/
export const stripTerminalDisplayToPlainText = (
chunk: string,
pending: string = "",
): { text: string; pending: string } => {
const input = pending + chunk;
let output = "";
let i = 0;
while (i < input.length) {
if (isEscapeIntroducer(input[i])) {
const end = consumeEscapeSequence(input, i);
if (end === null) {
const rest = input.slice(i);
if (rest.length <= MAX_PENDING_ESCAPE_CHARS) {
return { text: output, pending: rest };
}
// Cap the retained tail but keep its introducer: without it the payload
// of an oversized control string would be re-parsed as transcript text
// on the next chunk.
const introducerLength = escapeIntroducerLength(input, i);
return {
text: output,
pending: rest.slice(0, introducerLength)
+ rest.slice(rest.length - (MAX_PENDING_ESCAPE_CHARS - introducerLength)),
};
}
const eraseInLine = eraseInLineMarkerFor(input, i, end);
if (eraseInLine) output += eraseInLine;
i = end;
continue;
}
const next = findEscapeIntroducer(input, i + 1);
const end = next === -1 ? input.length : next;
const kept = keepTranscriptChars(input.slice(i, end));
if (kept) output += kept;
i = end;
}
return { text: output, pending: "" };
};
const isAsciiOnly = (text: string): boolean => {
for (let i = 0; i < text.length; i += 1) {
if (text.charCodeAt(i) > 0x7f) return false;
}
return true;
};
/** Cell columns a transcript span occupies (wide glyphs count twice). */
const pieceCellWidth = (text: string): number =>
isAsciiOnly(text) ? text.length : stringCellWidth(text);
/**
* Wrap one transcript line into viewport-sized rows. Continuation rows carry
* the same `isWrapped` flag xterm uses so soft-wrapped selection joins keep
* working (see joinHistoryPreviewSelectionText).
*/
export const wrapOutputHistoryLineToRows = (text: string, cols: number): string[] => {
if (cols < 1) return [text];
const ascii = isAsciiOnly(text);
const width = ascii ? text.length : stringCellWidth(text);
if (width <= cols) return [text];
const rows: string[] = [];
let startCell = 0;
while (startCell < width) {
// ASCII lines skip the grapheme segmenter; a transcript can hold long lines.
const row = ascii
? text.slice(startCell, startCell + cols)
: sliceStringByCellColumns(text, startCell, startCell + cols);
if (!row) break;
rows.push(row);
startCell += ascii ? row.length : stringCellWidth(row);
}
return rows.length > 0 ? rows : [text];
};
const buildPreviewRows = (lines: readonly string[], cols: number): HistoryPreviewRow[] => {
const rows: HistoryPreviewRow[] = [];
for (const line of lines) {
const wrapped = wrapOutputHistoryLineToRows(line, cols);
for (let row = 0; row < wrapped.length; row += 1) {
rows.push({ isWrapped: row > 0, text: wrapped[row] });
}
}
return rows;
};
export const clampOutputHistoryPreviewTop = (
top: number,
totalRows: number,
rows: number,
): number => {
const maxTop = Math.max(0, totalRows - Math.max(1, rows));
return Math.max(0, Math.min(maxTop, top));
};
export const nextOutputHistoryPreviewTop = ({
currentTop,
lines,
rows,
totalRows,
}: {
currentTop: number | null;
lines: number;
rows: number;
totalRows: number;
}): number => clampOutputHistoryPreviewTop(
clampOutputHistoryPreviewTop(
currentTop ?? Math.max(0, totalRows - Math.max(1, rows)),
totalRows,
rows,
) + lines,
totalRows,
rows,
);
export interface OutputHistoryPreviewWindow {
rows: HistoryPreviewRow[];
totalRows: number;
}
export interface TerminalOutputHistoryPreview {
append(chunk: string): void;
clear(): void;
/** Transcript lines, oldest first (the open last line included). */
getLines(): readonly string[];
/** Viewport rows the retained transcript wraps to at `cols` columns. */
getPreviewRowCount(cols: number): number;
getPreviewRows(params: { cols: number; rows: number; top: number }): OutputHistoryPreviewWindow;
}
export const createTerminalOutputHistoryPreview = (options?: {
maxLines?: number;
maxChars?: number;
}): TerminalOutputHistoryPreview => {
const maxLines = Math.max(1, options?.maxLines ?? DEFAULT_OUTPUT_HISTORY_MAX_LINES);
const maxChars = Math.max(1, options?.maxChars ?? DEFAULT_OUTPUT_HISTORY_MAX_CHARS);
let lines: string[] = [];
let current = "";
let cursor = 0;
let cursorCell = 0;
let totalChars = 0;
let pendingEscape = "";
let cacheDirty = true;
let cacheCols = -1;
let cacheRows: HistoryPreviewRow[] = [];
const commitCurrentLine = () => {
lines.push(current);
totalChars += current.length;
current = "";
cursor = 0;
cursorCell = 0;
// Never trim away the last retained line.
while (lines.length > maxLines || (totalChars > maxChars && lines.length > 1)) {
const dropped = lines.shift();
if (dropped === undefined) break;
totalChars -= dropped.length;
}
};
/**
* Write one span at the open line's cursor. A cursor-addressed redraw
* (cursor-home CSI stripped, no LF) would keep appending frames to the open
* line forever, so the budget is enforced here: spans are written in bounded
* pieces, committing a full line at the cap, so nothing is dropped and the
* copy every write makes stays bounded.
*/
const writeSpan = (span: string) => {
let offset = 0;
while (offset < span.length) {
if (current.length >= maxChars) commitCurrentLine();
const piece = span.slice(offset, offset + (maxChars - current.length));
if (!piece) return;
current = cursor >= current.length
? current + piece
: current.slice(0, cursor) + piece + current.slice(cursor + piece.length);
cursor = cursor >= current.length ? current.length : cursor + piece.length;
cursorCell += pieceCellWidth(piece);
offset += piece.length;
}
};
const writeText = (text: string) => {
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === "\n") {
commitCurrentLine();
i += 1;
continue;
}
if (ch === "\r") {
cursor = 0;
cursorCell = 0;
i += 1;
continue;
}
if (ch === "\b") {
cursor = Math.max(0, cursor - 1);
cursorCell = Math.max(0, cursorCell - 1);
i += 1;
continue;
}
if (ch === ERASE_TO_END_OF_LINE) {
current = current.slice(0, cursor);
i += 1;
continue;
}
if (ch === "\t") {
// Expand to the next 8-column tab stop so row widths match how the
// preview overlay renders the text; a literal tab renders at the tab
// stop and would be clipped instead of wrapped onto a continuation row.
const padding = TERMINAL_TAB_STOP_COLUMNS - (cursorCell % TERMINAL_TAB_STOP_COLUMNS);
writeSpan(" ".repeat(padding));
i += 1;
continue;
}
let end = i;
while (
end < text.length
&& text[end] !== "\n"
&& text[end] !== "\r"
&& text[end] !== "\b"
&& text[end] !== "\t"
&& text[end] !== ERASE_TO_END_OF_LINE
) {
end += 1;
}
writeSpan(text.slice(i, end));
i = end;
}
};
const getLines = (): readonly string[] => (current ? [...lines, current] : lines);
const refreshPreviewCache = (cols: number) => {
if (!cacheDirty && cacheCols === cols) return;
cacheRows = buildPreviewRows(getLines(), cols);
cacheCols = cols;
cacheDirty = false;
};
return {
append(chunk: string): void {
if (!chunk) return;
const { text, pending } = stripTerminalDisplayToPlainText(chunk, pendingEscape);
pendingEscape = pending;
if (!text) return;
writeText(text);
cacheDirty = true;
},
clear(): void {
lines = [];
current = "";
cursor = 0;
cursorCell = 0;
totalChars = 0;
pendingEscape = "";
cacheDirty = true;
},
getLines,
getPreviewRowCount(cols: number): number {
refreshPreviewCache(cols);
return cacheRows.length;
},
getPreviewRows({ cols, rows: visibleRows, top }): OutputHistoryPreviewWindow {
const columnCount = Math.max(1, cols);
const rowCount = Math.max(1, visibleRows);
refreshPreviewCache(columnCount);
const totalRows = cacheRows.length;
const clampedTop = clampOutputHistoryPreviewTop(top, totalRows, rowCount);
const window = cacheRows.slice(clampedTop, clampedTop + rowCount);
while (window.length < rowCount) window.push({ isWrapped: false, text: "" });
return { rows: window, totalRows };
},
};
};
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MAX_RAW_PASTE_PER_CHARACTER_LENGTH,
getTextInputWireChunks,
shouldSplitImeTextInputForWire,
shouldSplitRawPasteInputForWire,
splitTextIntoCodePointWrites,
} from "./terminalPerCharacterInput";
test("splitTextIntoCodePointWrites keeps surrogate pairs on one write", () => {
assert.deepEqual(splitTextIntoCodePointWrites("a中👍b"), ["a", "中", "👍", "b"]);
assert.deepEqual(splitTextIntoCodePointWrites("👍"), ["👍"]);
assert.deepEqual(splitTextIntoCodePointWrites(""), []);
});
test("IME commits with more than one character split per character", () => {
assert.equal(shouldSplitImeTextInputForWire("中国"), true);
assert.equal(shouldSplitImeTextInputForWire("abc"), true);
assert.equal(shouldSplitImeTextInputForWire("中a👍"), true);
});
test("single-character IME commits keep the single write", () => {
assert.equal(shouldSplitImeTextInputForWire("中"), false);
assert.equal(shouldSplitImeTextInputForWire("👍"), false);
assert.equal(shouldSplitImeTextInputForWire(","), false);
assert.equal(shouldSplitImeTextInputForWire(""), false);
});
test("IME commits carrying escape sequences never split", () => {
assert.equal(shouldSplitImeTextInputForWire("\x1b[200~中\x1b[201~"), false);
assert.equal(shouldSplitImeTextInputForWire("\x1b[0;;200u"), false);
assert.equal(shouldSplitImeTextInputForWire("\x1ba"), false);
});
test("short raw pastes split per character", () => {
assert.equal(shouldSplitRawPasteInputForWire("10.1.2.3"), true);
assert.equal(shouldSplitRawPasteInputForWire("中文"), true);
assert.equal(shouldSplitRawPasteInputForWire("ab"), true);
assert.equal(
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH)),
true,
);
});
test("single-character and long raw pastes keep the single write", () => {
assert.equal(shouldSplitRawPasteInputForWire("a"), false);
assert.equal(
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH + 1)),
false,
);
assert.equal(shouldSplitRawPasteInputForWire(""), false);
});
test("raw pastes containing escape sequences never split", () => {
assert.equal(shouldSplitRawPasteInputForWire("\x1b[200~ab\x1b[201~"), false);
assert.equal(shouldSplitRawPasteInputForWire("\x1b[A"), false);
assert.equal(shouldSplitRawPasteInputForWire("a\x1b"), false);
});
test("wire chunking honors the per-character request only for plain text", () => {
assert.deepEqual(getTextInputWireChunks("ab", false), ["ab"]);
assert.deepEqual(getTextInputWireChunks("ab", true), ["a", "b"]);
assert.deepEqual(getTextInputWireChunks("a中👍", true), ["a", "中", "👍"]);
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});
@@ -0,0 +1,45 @@
/**
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
export const isPlainTerminalInputText = (data: string): boolean => !data.includes(ESC);
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per glyph. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
/**
* Raw (non-bracketed) paste: short plain text goes out as keystrokes, longer
* pastes keep the single write so bulk pastes do not degrade.
*/
export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
if (data.length <= 1 || !isPlainTerminalInputText(data)) return false;
let codePoints = 0;
for (let index = 0; index < data.length; ) {
index += (data.codePointAt(index) ?? 0) > 0xffff ? 2 : 1;
codePoints += 1;
if (codePoints > MAX_RAW_PASTE_PER_CHARACTER_LENGTH) return false;
}
return true;
};
/** Chunks to write for one input payload; escape sequences are never split. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
? splitTextIntoCodePointWrites(data)
: [data];
+3 -1
View File
@@ -171,7 +171,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -432,6 +432,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
isBootActiveRef.current = true;
terminalDataCapturedRef.current = false;
connectionLogBufferRef.current.reset();
terminalOutputHistory?.clear();
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
@@ -519,6 +520,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
serialLineBufferRef,
telnetLocalEchoRef,
onTerminalLogData: captureTerminalLogData,
terminalOutputHistory,
onCwdChange: (cwd: string) => {
onPluginRuntimeCwdChange(cwd, { source: 'osc7' });
},
@@ -1,5 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const Module = require("node:module");
@@ -15,6 +16,12 @@ const {
waitForTransportDial,
} = require("./sshConnectionPool.cjs");
const TEST_PRIVATE_KEY = crypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "sec1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
test.beforeEach(() => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
@@ -579,6 +586,13 @@ function loadBridgeWithAuthRetryMocks(t, options = {}) {
return { bridge, MockSSHClient };
}
function createParsedPrivateKey() {
return {
isPrivateKey: () => true,
getPrivatePEM: () => TEST_PRIVATE_KEY,
};
}
test("terminal SSH supports consecutive keyboard-interactive factors (#2150)", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["repeated-keyboard-interactive"],
@@ -1028,7 +1042,7 @@ test("terminal SSH keeps keyboard-interactive eligible after password rejection"
test("terminal SSH prefers keyboard-interactive after publickey partial success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["publickey-then-password-and-keyboard-interactive"],
parseKeyResult: {},
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
@@ -1056,7 +1070,7 @@ test("terminal SSH prefers keyboard-interactive after publickey partial success"
hostname: "corp-edr.example.com",
username: "alice",
authMethod: "key",
privateKey: "INLINE_PRIVATE_KEY",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
@@ -1080,7 +1094,7 @@ test("terminal SSH prefers keyboard-interactive after publickey partial success"
test("terminal SSH certificate auth prefers keyboard-interactive after agent partial success after partial success", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["agent-then-password-and-keyboard-interactive"],
parseKeyResult: {},
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
@@ -1109,7 +1123,7 @@ test("terminal SSH certificate auth prefers keyboard-interactive after agent par
username: "alice",
authMethod: "certificate",
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: "INLINE_PRIVATE_KEY",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
@@ -1131,7 +1145,7 @@ test("terminal SSH certificate auth prefers keyboard-interactive after agent par
test("terminal SSH certificate requiresMfa prefers keyboard-interactive after a rejected certificate", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithAuthRetryMocks(t, {
connectEvents: ["agent-then-mfa-keyboard-interactive-before-password"],
parseKeyResult: {},
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
@@ -1147,7 +1161,7 @@ test("terminal SSH certificate requiresMfa prefers keyboard-interactive after a
authMethod: "certificate",
requiresMfa: true,
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: "INLINE_PRIVATE_KEY",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
@@ -1172,7 +1186,7 @@ test("terminal SSH certificate auth retries keyboard-interactive when password r
"agent-password-removes-keyboard-interactive",
"agent-then-keyboard-interactive-after-skip-password",
],
parseKeyResult: {},
parseKeyResult: createParsedPrivateKey(),
});
const ipcMain = makeIpcMain();
bridge.init({ sessions: new Map(), electronModule: {} });
@@ -1201,7 +1215,7 @@ test("terminal SSH certificate auth retries keyboard-interactive when password r
username: "alice",
authMethod: "certificate",
certificate: "ssh-rsa-cert-v01@openssh.com AAAA test-cert",
privateKey: "INLINE_PRIVATE_KEY",
privateKey: TEST_PRIVATE_KEY,
password: "login-password",
useSshAgent: false,
port: 22,
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.81";
version = "1.1.82";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-WkHgVhYfmqM24Zk37tAAa3uqUPtvh7jfN2nV3SQ+ong=";
hash = "sha256-cTmVXxZFSfsWgjY/7ZZ7aYCyvXrlbqe6DFki+Wcfpqg=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-TMRFLvD0hW7rLtdg4ktMoV7hLv+4vHkucCTnvv61axQ=";
hash = "sha256-k6abS+JCVkf6Sj4+gAGsI8k5hiYJ3gZJHifsuIcI7/8=";
};
};
}