From 352663cd960eb9a19e9319840980e31df369e48a Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:21:21 +0800 Subject: [PATCH 1/4] feat(core): track DECCKM and DECTCEM mode bits refs #28 Private ?1 and ?25 are parsed into two explicit mode bits with accessors, the strict-mode reply test covers them, and the unsupported private samples move to an unimplemented sequence. --- experiments/core/alternate_mode_test.cpp | 4 ++-- experiments/core/reply_test.cpp | 12 ++++++++++++ experiments/core/terminal.hpp | 12 ++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/experiments/core/alternate_mode_test.cpp b/experiments/core/alternate_mode_test.cpp index 3cf2d045..d126e78a 100644 --- a/experiments/core/alternate_mode_test.cpp +++ b/experiments/core/alternate_mode_test.cpp @@ -144,7 +144,7 @@ static void readers_follow_protocol_transitions(ParseMode mode) { static void recovery_keeps_private_sequences_separate() { const std::vector ignored{ "\x1b[?31m", "\x1b[?1049;25h", "\x1b[?1049;h", "\x1b[0?1049h", - "\x1b[??1049h", "\x1b[?1049 h", "\x1b[?1049:1h", "\x1b[?25l", + "\x1b[??1049h", "\x1b[?1049 h", "\x1b[?1049:1h", "\x1b[?12l", std::string("\x1b[?") + std::string(33, '0') + "1049h"}; for (const auto& sequence : ignored) { Parser parser(2, 8, 1, ParseMode::recovery); @@ -181,7 +181,7 @@ static void identical_repeated_text_is_a_new_document(ParseMode mode) { parser.finish(); } static void strict_rejects_unsupported_private_modes_atomically() { - for (const auto* input : {"\x1b[?31m", "\x1b[?1049;25h", "\x1b[?1049;1049h", "\x1b[?25l"}) { + for (const auto* input : {"\x1b[?31m", "\x1b[?1049;25h", "\x1b[?1049;1049h", "\x1b[?12l"}) { Parser parser(2, 8, 1); parser.feed("\x1b[31;44mMAIN"); bool rejected = false; diff --git a/experiments/core/reply_test.cpp b/experiments/core/reply_test.cpp index 3e7b8654..a30de673 100644 --- a/experiments/core/reply_test.cpp +++ b/experiments/core/reply_test.cpp @@ -26,6 +26,18 @@ int main() { // An unimplemented report must stay unimplemented instead of being faked. parser.feed("\x1b[5n"); require(parser.take_replies().empty(), "unsupported report produced a reply"); + // DECCKM and DECTCEM are state-only modes; strict parsing must accept them. + { + Parser strict_parser(3, 10, 10, ParseMode::strict); + require(strict_parser.cursor_visible() && !strict_parser.application_cursor_keys(), + "default mode bits mismatch"); + strict_parser.feed("\x1b[?1h\x1b[?25l"); + require(strict_parser.application_cursor_keys() && !strict_parser.cursor_visible(), + "mode bits were not applied"); + strict_parser.feed("\x1b[?1l\x1b[?25h"); + require(!strict_parser.application_cursor_keys() && strict_parser.cursor_visible(), + "mode bits were not cleared"); + } std::puts("cursor reply contracts passed"); return 0; } diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index d5c4ac35..47b7e18a 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -29,8 +29,17 @@ class Parser { std::string encoded; // Bounded replies for implemented queries; drained by the host after each feed. std::vector pending_replies; + // Bit 0: DECCKM application cursor keys; bit 1: DECTCEM cursor visible. + uint32_t mode_bits_ = 1u << 1; void dispatch(unsigned char final) { if (private_csi) { + // DECCKM (?1) and DECTCEM (?25) only change terminal state the host + // reads later; everything else private is still unsupported. + if (params.size() == 1 && (params[0] == 1 || params[0] == 25) && (final == 'h' || final == 'l')) { + const uint32_t bit = params[0] == 1 ? 1u << 0 : 1u << 1; + if (final == 'h') mode_bits_ |= bit; else mode_bits_ &= ~bit; + return; + } if (params.size() != 1 || (params[0] != 1049 && params[0] != 7) || (final != 'h' && final != 'l')) throw InputError("unsupported private CSI command"); if (params[0] == 7) { @@ -240,6 +249,9 @@ class Parser { : mode(selected), screen(rows, columns, history) {} const RecoveryDiagnostics& diagnostics() const { return stats; } std::vector take_replies() { return std::exchange(pending_replies, {}); } + uint32_t mode_bits() const { return mode_bits_; } + bool application_cursor_keys() const { return (mode_bits_ & (1u << 0)) != 0; } + bool cursor_visible() const { return (mode_bits_ & (1u << 1)) != 0; } size_t buffered_input_bytes() const { return params.size() * sizeof(int) + encoded.size(); } void feed(std::string_view bytes) { if (mode == ParseMode::strict) feed_strict(bytes); From e59dd23ff39dc959327f6681e51e25b6d7e80a6e Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:26:19 +0800 Subject: [PATCH 2/4] test(core): keep a still-unsupported private sample in the strict rejection case refs #28 Cursor visibility is handled now, so the strict-mode negative sample moves to an unimplemented private mode. --- experiments/core/test_recovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experiments/core/test_recovery.py b/experiments/core/test_recovery.py index f0bba2ca..ba670591 100644 --- a/experiments/core/test_recovery.py +++ b/experiments/core/test_recovery.py @@ -252,7 +252,8 @@ def test_unterminated_string_consumes_all_until_eof(self): self.expect(b"A\x1b]hidden\r\nZ", "A", stats={"truncated_sequences": 1}) def test_strict_still_rejects_invalid_input(self): - for data in (b"\x1b7", b"\x1b]title\x07", b"\xff", b"\xe1\x80", b"\x1b[?25h", b"\x1b[31"): + # ?25 is a supported mode now, so the unsupported-private sample uses ?12. + for data in (b"\x1b7", b"\x1b]title\x07", b"\xff", b"\xe1\x80", b"\x1b[?12h", b"\x1b[31"): result = self.run_input(data, [1], mode="strict") self.assertEqual(result.returncode, 2) self.assertEqual(result.stdout, "") From fd878df04444050711e67545aa8d2ff44a04e451 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:28:04 +0800 Subject: [PATCH 3/4] feat(shell): send SS3 cursor keys when DECCKM is set refs #28 The keyboard encoder takes the application-cursor flag, the shell passes the parser mode bit, and keyboard_test covers the SS3 forms plus the unchanged modified and editing keys. --- experiments/shell/keyboard.hpp | 11 +++++++---- experiments/shell/keyboard_test.cpp | 15 +++++++++++++++ experiments/shell/shell.cpp | 6 +++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/experiments/shell/keyboard.hpp b/experiments/shell/keyboard.hpp index fedbc9f2..546a1edc 100644 --- a/experiments/shell/keyboard.hpp +++ b/experiments/shell/keyboard.hpp @@ -52,8 +52,8 @@ class KeyboardEncoder { return out + encoded(unit, alt); } // Win32 virtual-key values. Printable/control text stays in WM_CHAR; - // application cursor/keypad modes remain a separate future contract. - static KeyEncoding key_down(uint32_t key, KeyModifiers mods = {}) { + // DECCKM switches the unmodified cursor keys between CSI and SS3 forms. + static KeyEncoding key_down(uint32_t key, KeyModifiers mods = {}, bool application_cursor = false) { if (mods.alt_graph || (mods.control && mods.alt)) return {}; const int modifier = 1 + (mods.shift ? 1 : 0) + (mods.alt ? 2 : 0) + (mods.control ? 4 : 0); if (key == 0x09) { // Plain Tab is translated once, by WM_CHAR. @@ -71,8 +71,11 @@ class KeyboardEncoder { case 0x23: final = 'F'; break; default: break; } - if (final) return {true, modifier == 1 ? std::string("\x1b[") + final : - std::string("\x1b[1;") + std::to_string(modifier) + final, false}; + if (final) { + if (modifier == 1 && application_cursor) return {true, std::string("\x1bO") + final, false}; + return {true, modifier == 1 ? std::string("\x1b[") + final : + std::string("\x1b[1;") + std::to_string(modifier) + final, false}; + } int number = 0; switch (key) { case 0x2d: number = 2; break; // Insert diff --git a/experiments/shell/keyboard_test.cpp b/experiments/shell/keyboard_test.cpp index 5d7dfb73..317f6fe9 100644 --- a/experiments/shell/keyboard_test.cpp +++ b/experiments/shell/keyboard_test.cpp @@ -1,6 +1,9 @@ #include "keyboard.hpp" #include #include +#include +#include +#include using namespace tf_shell; static void check(bool value, const char* why) { if (!value) throw std::runtime_error(why); } int main() { @@ -30,6 +33,18 @@ int main() { const auto encoded = KeyboardEncoder::key_down(pair.first); check(encoded.handled && encoded.bytes == pair.second && !encoded.skip_translate, "base navigation encoding"); } + // DECCKM only rewrites the unmodified cursor keys to their SS3 form. + const std::vector> application{ + {0x25,"\x1bOD"}, {0x26,"\x1bOA"}, {0x27,"\x1bOC"}, {0x28,"\x1bOB"}, + {0x24,"\x1bOH"}, {0x23,"\x1bOF"}}; + for (const auto& entry : application) { + const auto encoded = KeyboardEncoder::key_down(entry.first, {}, true); + check(encoded.handled && encoded.bytes == entry.second, "application cursor encoding"); + } + check(KeyboardEncoder::key_down(0x24, {false,true,false,false}, true).bytes == "\x1b[1;5H", + "application mode must not rewrite modified cursor keys"); + check(KeyboardEncoder::key_down(0x2e, {}, true).bytes == "\x1b[3~", + "application mode must not rewrite editing keys"); check(KeyboardEncoder::key_down(0x24, {false,true,false,false}).bytes == "\x1b[1;5H", "Ctrl Home"); check(KeyboardEncoder::key_down(0x2e, {true,false,true,false}).bytes == "\x1b[3;4~", "Shift Alt Delete"); check(!KeyboardEncoder::key_down(0x09).handled, "plain Tab belongs only to WM_CHAR"); diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 866fb0a8..3b4cacf8 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -827,6 +827,9 @@ class ShellWindow { } } } +#endif +#ifndef TERMFORGE_CANDIDATE_CORE + bool application_cursor_keys() const { return parser_.application_cursor_keys(); } #endif void tick() { const auto began = Clock::now(); ++result_.ticks; @@ -1006,7 +1009,8 @@ class ShellWindow { if(!mods.control && !mods.alt && wp==VK_F3) { self->navigate_find(mods.shift?termforge::SearchDirection::previous:termforge::SearchDirection::next); return 0; } - const auto key=tf_shell::KeyboardEncoder::key_down(static_cast(wp),mods); + const auto key=tf_shell::KeyboardEncoder::key_down(static_cast(wp),mods, + self->application_cursor_keys()); if(key.handled) { self->send_input(key.bytes); return 0; } } if (message == WM_TIMER && (self->started_ || self->closing_)) { self->tick(); return 0; } From 6590ed2008e46a01dd936dbc9caf9c65db5c765c Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:34:39 +0800 Subject: [PATCH 4/4] fix(shell): keep the candidate build compiling without DECCKM refs #28 The candidate terminal does not expose the mode bit yet, so the helper returns false there instead of referencing a method it lacks. --- experiments/shell/shell.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 3b4cacf8..db148aa7 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -828,7 +828,11 @@ class ShellWindow { } } #endif -#ifndef TERMFORGE_CANDIDATE_CORE +#ifdef TERMFORGE_CANDIDATE_CORE + // The candidate terminal does not expose DECCKM yet; keep the previous CSI + // encoding instead of guessing at its mode state. + bool application_cursor_keys() const { return false; } +#else bool application_cursor_keys() const { return parser_.application_cursor_keys(); } #endif void tick() {