Skip to content

ENQ answerback, OSC colour report format, and opt-in KAM - #181

Merged
tomlm merged 6 commits into
tomlm:mainfrom
confetty-sh:foxery/enq-osc-report-format-kam-options
Oct 31, 2026
Merged

tomlm merged 6 commits into
tomlm:mainfrom
confetty-sh:foxery/enq-osc-report-format-kam-options

Conversation

@JohnCampionJr

@JohnCampionJr JohnCampionJr commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Three small opt-in options, for parity with Ghostty's enquiry-response, osc-color-report-format and vt-kam-allowed. Each feature is its own commit, and review fixes follow as separate commits. Every default keeps today's behaviour, including DECRQM reporting KAM as xterm and esctest expect.

TerminalOptions.EnquiryResponse (ENQ answerback)

HandleExecute dropped ENQ (0x05). When this string is non-empty, ENQ replies with it. The reply is sent through RaiseInputData, not RaiseDataReceived, because the answerback is the user's literal string: the S8C1T converter would otherwise rewrite a leading ESC in it as one of the terminal's own reports. The case widens the execute switch's jump table, so LF, CR and BS pay no extra compare.

TerminalOptions.ColorReportFormat (None, EightBit, SixteenBit; default SixteenBit)

Every OSC colour reply (OSC 4 ?, the OSC 4/5 special colours, OSC 10/11/12) now goes through one ReplyColor helper:

  • SixteenBit replies with rgb:rrrr/gggg/bbbb, as before.
  • EightBit replies with rgb:rr/gg/bb.
  • None sends no reply.

Only an explicit None is silent. A value outside the enum falls back to the sixteen-bit default.

TerminalOptions.KeyboardActionModeAllowed (default false) and Terminal.KeyboardLocked

KAM (ANSI mode 2) used to be recorded but never acted on.

  • Always stored and reported. SM 2 and RM 2 set and reset mode 2, and DECRQM reports it whatever the option says, as xterm does and esctest's test_DECRQM_ANSI_KAM expects. This is unchanged from main.
  • The option gates only the lock. KeyboardLocked is true exactly when mode 2 is set and KeyboardActionModeAllowed is on. While it is locked, GenerateKeyInput, GenerateCharInput and GenerateKittyKeyInput produce nothing, and Paste sends nothing. Hosts that encode their own input should check KeyboardLocked too.
  • The option is read live. Turning it off unlocks at once, which gives the host a way out for a user that a crashed program left locked. Turning it on while mode 2 is set locks at once.
  • Resetting: RM 2, RIS and DECSTR reset mode 2, and with it the lock.

A default terminal therefore can't be locked, but still reports KAM faithfully.

Terminal.KeyboardLockedChanged

A host showing a "keyboard locked" indicator no longer has to poll.

public event EventHandler<TerminalEvents.KeyboardLockedEventArgs>? KeyboardLockedChanged;   // args.Locked = the new KeyboardLocked
  • Raised only when the effective value changes: through SM 2, RM 2, RIS or DECSTR, or the host flipping KeyboardActionModeAllowed. Nothing fires for SM 2 while the option is off, SM 2 when already locked, or a flip while mode 2 is reset.
  • Threading: raised synchronously, after the new value is in place. For SM 2, RM 2, RIS and DECSTR that's the thread calling Write; for an option flip, the thread that set it.
  • Conventions: it follows SynchronizedOutputChanged / SynchronizedOutputEventArgs. Option flips reach it through an internal hook installed like ScrollbackChanged, and Dispose clears both.

Tests

New EnquiryResponseTests, ColorReportFormatTests, KeyboardActionModeTests and KeyboardLockedChangedTests. KAM's case is kept in RequestModeTests.Stored_ansi_modes_toggle_and_report. The whole XTerm.NET.Tests project passes: 2386 passed, 1 skipped (the existing DA3 skip), 0 failed. Mutants that raise on every write of the mode or the option are each caught.

🤖 Generated with Claude Code

JohnCampionJr and others added 3 commits October 31, 2026 14:40
ENQ (0x05) was dropped: the execute switch began at BEL. It now sends
EnquiryResponse, xterm's answerbackString, when that is non-empty; empty
stays the default, so nothing changes for an existing host.

The case sits in the same dense switch, which compiles to a jump table,
so LF, CR and BS pay no extra compare for it. A host handler registered
for 0x05 runs in the parser before this and still wins when it consumes
the control. The reply goes out as input-direction data with no S8C1T
rewrite, since it is the user's literal string, not a report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rmat

OSC 4, 5 and 10/11/12 queries always answered rgb:rrrr/gggg/bbbb. The new
option keeps that as SixteenBit, the default, and adds EightBit
(rgb:rr/gg/bb) and None (no reply; setting colours is unaffected).

All three reply sites -- the palette, the special colours, and the
dynamic colours -- now go through one ReplyColor helper so none can miss
the option. There are no OSC 17/19 or other colour replies to cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SM 2 was stored and reported by DECRQM, and nothing read it. Now:

- Off (the default), SM 2 is ignored and DECRQM reports mode 2 reset,
  as Ghostty does. A program that locks the keyboard and dies leaves
  the user unable to type.
- On, SM 2 sets the new read-only Terminal.KeyboardLocked, and the
  library's own input paths drop input while it is set:
  GenerateKeyInput and GenerateCharInput return empty,
  GenerateKittyKeyInput returns null, and Paste sends nothing.
- RM 2, RIS and DECSTR clear it. RM 2 is honoured even with the option
  off, so turning the option off cannot strand a locked keyboard.

The stored-modes theory in RequestModeTests no longer lists KAM, since
toggling it by default is the behaviour this changes; the toggle is now
asserted with the option on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tomlm tomlm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds three opt-in options for Ghostty parity: EnquiryResponse (ENQ answerback), ColorReportFormat (OSC 4/5/10/11/12 reply width, or no reply), and KeyboardActionModeAllowed with a new Terminal.KeyboardLocked that the key, char, Kitty and paste paths check.

The ENQ and colour-format halves are sound. The KAM half changes default behaviour in a way that contradicts a test the repo deliberately added.

Medium

  • KAM refused by default breaks esctest test_DECRQM_ANSI_KAM, and the test that pinned it was deleted rather than reconciled (InputHandler.StoredModes.cs:87). xterm, which the repo grades against, toggles and reports KAM. This PR changes the default path to Ghostty's behaviour without making it opt-in.

Doc comments

  • Terminal.KeyboardLocked remarks say it is "only ever true when KeyboardActionModeAllowed is". TerminalOptions.KeyboardActionModeAllowed and the PR's own test Disallowing_after_a_lock_still_lets_RM_2_unlock both say the opposite (Terminal.cs:53).
  • The RequestModeTests summary still justifies KAM being stored with "esctest toggles them and expects the toggle reported". That reason no longer holds for KAM, and the new line added under it contradicts it (RequestModeTests.cs:304).

Notes (checked, fine)

  • ENQ dispatch: the parser routes 0x05 through OnExecute, so a registered execute handler runs first and a consuming one suppresses the reply. The TerminalOptions remark is accurate. Inside OSC and DCS payloads ENQ is not executed, and the tests cover both. Adding case 0x05 only moves the jump table's base from 7 to 5, so LF, CR and BS pay no extra compare, as the PR says.
  • ENQ reply path: sending through RaiseInputData with no C1 conversion matches xterm's CASE_ENQ, which unparseputcs answer_back byte for byte. A null EnquiryResponse is handled by the is { Length: > 0 } pattern.
  • Colour replies: every colour reply in InputHandler.Osc.cs now goes through ReplyColor. I grepped for rgb: and RaiseDataReceived and found no other colour-reply site. The OSC terminator is still mirrored. EightBit rgb:rr/gg/bb matches Ghostty's 8-bit format. CopyFrom copies all three new options.
  • Unlocking: DECSTR unlocking KAM matches the VT510 DECSTR table. Both RIS and DECSTR reach ResetStoredModes, which now clears KeyboardLocked.
  • Mouse input: GenerateMouseEvent and GenerateFocusEvent are not gated by KAM. That is correct, because KAM locks the keyboard only.
  • Low: ColorSpec.Format(int, ColorReportFormat) returns null, meaning no reply, for any out-of-range enum value as well as None. A host that casts a bad int gets silence rather than an error.
  • No new hot-path cost. TrySetStoredMode and the input-generation guards sit off the parse and print paths.

// KAM locks the keyboard, which a program can do to a user but not undo for them if it
// dies -- so it is refused unless the host opts in, and stays reported reset, as Ghostty
// does. RM 2 is always honoured: unlocking is never the risk.
if (value && !_terminal.Options.KeyboardActionModeAllowed)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: refusing SM 2 by default regresses DECRQM conformance against xterm and esctest, and the test that encoded it was removed rather than reconciled.

With the default options, CSI 2 h followed by CSI 2 $ p now answers CSI 2;2 $ y. Before this PR it answered CSI 2;1 $ y. esctest2 decrqm.py:

def test_DECRQM_ANSI_KAM(self):
    self.doModifiableAnsiTest(esccmd.KAM)
...
  esccmd.SM(mode)
  AssertEQ(self.requestAnsiMode(mode), [mode, 1])

That now fails on a default terminal. xterm, the grading key elsewhere in this repo (see the DECARM and DECKPM comments in this file), sets MODE_KAM on SM 2 and reports it through DECRQM. RequestModeTests.Stored_ansi_modes_toggle_and_report had [InlineData(2)] precisely because aca3941 made KAM reportable for esctest. This PR deletes that case instead of keeping it green. CLAUDE.md lists this pattern under "things that have bitten twice": check whether an existing test already encodes the behaviour.

The safety goal does not require lying to DECRQM. The pre-PR behaviour was "stored and reported, not acted on", and that is what a disallowed KAM can keep doing:

if (!isPrivate && mode == 2)
    _terminal.KeyboardLocked = value && _terminal.Options.KeyboardActionModeAllowed;
table[mode] = value;

Done that way, a default terminal still cannot be locked, esctest stays green, and [InlineData(2)] can go back into the theory. If Ghostty's "report reset" is the deliberate choice, say so in the PR body. It is a user-visible change to the default path, not an opt-in.

@JohnCampionJr JohnCampionJr Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in c4184b5. SM 2 always stores and reports, as xterm does. KeyboardLocked is your expression, but read live: mode 2 is set AND KeyboardActionModeAllowed is on.

  • A default terminal still can't be locked.
  • Because it's live, turning the option off unlocks at once, which is the host's way out for a user that a dead program left locked.
  • Turning the option on while mode 2 is set locks at once.

Both are documented and tested. [InlineData(2)] is restored in Stored_ansi_modes_toggle_and_report.

Comment thread src/XTerm.NET/Terminal.cs Outdated
/// sends nothing; a host that encodes its own input should check this too.
/// </summary>
/// <remarks>
/// Only ever true when <see cref="TerminalOptions.KeyboardActionModeAllowed"/> is: otherwise

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc: this remark contradicts the option's own documentation and the PR's test.

KeyboardLocked can be true while KeyboardActionModeAllowed is false. TerminalOptions.KeyboardActionModeAllowed says "Turning this off does not unlock a keyboard already locked". KeyboardActionModeTests.Disallowing_after_a_lock_still_lets_RM_2_unlock asserts exactly that state: Options.KeyboardActionModeAllowed = false; Assert.True(terminal.KeyboardLocked);.

Suggest: "Only SM 2 sets it, and SM 2 is refused unless KeyboardActionModeAllowed is on at the time. Turning the option off later does not clear an existing lock. RM 2, RIS and DECSTR clear it."

@JohnCampionJr JohnCampionJr Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewritten in c4184b5:

  • KeyboardLocked is true exactly when mode 2 is set and the option is on.
  • DECRQM reports mode 2 whatever the option says.
  • Turning the option off unlocks; turning it on with mode 2 set locks.
  • RM 2, RIS and DECSTR reset it.

The contradicting test is replaced by Turning_the_option_off_unlocks_at_once_and_mode_2_stays_reported.

Comment thread src/XTerm.NET.Tests/RequestModeTests.cs Outdated
/// <summary>
/// KAM, SRM and LNM are STORED now -- set, reset and read back like any tracked mode, because
/// esctest (and the programs it stands in for) toggle them and expect the toggle reported.
/// KAM toggles only when the host allows it -- see KeyboardActionModeTests.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc: the summary's first two lines still give esctest as the reason KAM is stored and toggled. This PR makes that untrue by default, and the added third line contradicts them.

Either drop KAM from the first sentence and state why it now diverges from esctest's test_DECRQM_ANSI_KAM, or keep KAM reportable when disallowed (see the comment on InputHandler.StoredModes.cs:87) and put [InlineData(2)] back.

@JohnCampionJr JohnCampionJr Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c4184b5. KAM is reportable again, so the esctest reason holds for all three modes. [InlineData(2)] is back, and the added line now says KAM is reported whether or not the host lets it lock, pointing to KeyboardActionModeTests.

Review of tomlm#181: refusing SM 2 by default made DECRQM report mode 2 reset
after a set, which fails esctest's test_DECRQM_ANSI_KAM, and the
RequestModeTests case that pinned it had been deleted instead of kept.

KAM is now always stored and reported. KeyboardLocked is derived live as
"mode 2 set AND KeyboardActionModeAllowed", so a default terminal still
cannot be locked, turning the option off unlocks at once (the host's way
out for a user a dead program left locked), and turning it on while mode
2 is set locks. RM 2, RIS and DECSTR reset mode 2 and the lock with it.

[InlineData(2)] is back in Stored_ansi_modes_toggle_and_report, and the
docs on KeyboardLocked, KeyboardActionModeAllowed and the test summary
now describe that behaviour.

Also from the review: ColorSpec.Format(int, ColorReportFormat) is silent
only for an explicit None; an out-of-range value gets the sixteen-bit
default instead of no reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
Review of tomlm#181: refusing SM 2 by default made DECRQM report mode 2 reset
after a set, which fails esctest's test_DECRQM_ANSI_KAM, and the
RequestModeTests case that pinned it had been deleted instead of kept.

KAM is now always stored and reported. KeyboardLocked is derived live as
"mode 2 set AND KeyboardActionModeAllowed", so a default terminal still
cannot be locked, turning the option off unlocks at once (the host's way
out for a user a dead program left locked), and turning it on while mode
2 is set locks. RM 2, RIS and DECSTR reset mode 2 and the lock with it.

[InlineData(2)] is back in Stored_ansi_modes_toggle_and_report, and the
docs on KeyboardLocked, KeyboardActionModeAllowed and the test summary
now describe that behaviour.

Also from the review: ColorSpec.Format(int, ColorReportFormat) is silent
only for an explicit None; an out-of-range value gets the sixteen-bit
default instead of no reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JohnCampionJr

JohnCampionJr commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks. All taken in c4184b5:

  • KAM is now always stored and reported, and KeyboardActionModeAllowed gates only the lock. That restores esctest's test_DECRQM_ANSI_KAM and [InlineData(2)].
  • On the Low: only an explicit ColorReportFormat.None is silent now. An out-of-range value falls back to the sixteen-bit default, with a test.
  • XTerm.NET.Tests: 2370 passed, 1 skipped (DA3), 0 failed.

I've updated the PR description to match.

A host showing a "keyboard locked" indicator had to poll KeyboardLocked.
Now Terminal raises KeyboardLockedChanged (EventHandler of
TerminalEvents.KeyboardLockedEventArgs, carrying Locked) when the
effective value changes, and only then.

- SM 2, RM 2, RIS and DECSTR go through KeyboardActionMode, whose setter
  now ignores a write of the current value and raises only while the
  option allows the lock. Raised on the thread feeding Write.
- KeyboardActionModeAllowed gets a backing field and an internal
  KeyboardActionModeAllowedChanged hook, installed by the Terminal on its
  own snapshot like ScrollbackChanged and TabStopWidthChanged. A flip
  raises only while mode 2 is set, on the thread that set the option.
- Dispose clears the event and the options hook with their siblings,
  since the options snapshot can outlive the terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JohnCampionJr
JohnCampionJr force-pushed the foxery/enq-osc-report-format-kam-options branch from 4dcae26 to c4184b5 Compare October 31, 2026 19:27
@JohnCampionJr

JohnCampionJr commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

One addition since your review, in c0a37f6: Terminal.KeyboardLockedChanged, so a host can show a "keyboard locked" indicator without polling. It fires only when KeyboardLocked actually moves, whether through SM 2, RM 2, RIS, DECSTR or a flip of KeyboardActionModeAllowed. It's modelled on SynchronizedOutputChanged, and Dispose clears it.

The earlier commits are unchanged in content. Their messages were reworded, so the hashes in the replies above now read c4184b5 and friends.

The description has a new section on it. XTerm.NET.Tests: 2386 passed, 1 skipped, 0 failed.

JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
@tomlm
tomlm merged commit 92feb26 into tomlm:main Oct 31, 2026
4 checks passed
JohnCampionJr added a commit to confetty-sh/XTerm.NET that referenced this pull request Oct 31, 2026
tomlm added a commit to tomlm/Iciclecreek.Avalonia.Terminal that referenced this pull request Nov 1, 2026
…(KAM) (#197)

* Respect Terminal.KeyboardLocked (KAM) in the control's own input encoding

XTerm.NET (tomlm/XTerm.NET#181) gates its own key, char and Kitty generators
and Terminal.Paste while an application holds Keyboard Action Mode (CSI 2 h).
The view encodes much of the keyboard's output itself -- Win32 input records,
AltGr and composed characters, IME commits, the classic and bracketed paste,
the selection deletion, the ESC-b/ESC-f and Cmd+arrow aliases -- and all of
that went straight past the lock.

One helper, IsKeyboardLocked, read at the moment of encoding (there is no
change event), gates the keyboard entry points: OnKeyDown after the view's
local gestures and copy, OnKeyUp, OnTextInput (which also carries IME
commits), PasteAsync at the point of sending, CutAsync and SelectInputAsync.
A locked keystroke is consumed so Tab cannot move focus away; legacy Meta
chords and bare modifiers still bubble as before.

SendToPtyAsync is deliberately not gated: mouse reports, focus events and
the emulator's replies share it and keep flowing, as in xterm. SendInputAsync
is the host's channel, not the user's keyboard, and bypasses the lock; its
XML doc says so.

Depends on tomlm/XTerm.NET#181 (Terminal.KeyboardLocked,
TerminalOptions.KeyboardActionModeAllowed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keyboard lock: finish pre-lock keystrokes, drop stale deletions, keep selection

Review fixes for the KAM gate.

- A release whose press went out before the lock is sent: it finishes a
  keystroke the application already saw. Kitty presses are already tracked
  in _keysHeld; Win32 key-downs are now tracked in _win32KeysDown. The
  emulator's Kitty generator answers null for everything while locked, so a
  locked release is encoded with KittyKeyboard.Evaluate, which is all that
  generator does for a release. Releases of presses the lock dropped are
  still dropped.
- The locked TextInput return clears _pendingReplaceKeys, so a deletion taken
  by a key-down just before the lock landed cannot ride out with the next
  composed text after the unlock.
- CanCut asks IsKeyboardLocked, as CutAsync does, so a locked Cmd+X is left
  to the host instead of being claimed and cutting nothing.
- Keyboard selection (TryExtendKeyboardSelection) runs above the lock: it
  only moves the selection on the view. The guard's comment now says what
  is above it and what it stops below it, the standard paste chords included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Laird-McConnell <thermous@iciclecreek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants