From f4923a178241639f35fcdb2956e6c1f878e64a34 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:45:31 +0800 Subject: [PATCH 1/3] feat(core): reply to cursor position reports refs #28 Parser keeps a bounded reply queue for CSI 6n, the shell forwards replies through the same input queue as keyboard bytes, and reply_test covers the 1-based report plus the unimplemented-report negative. --- CMakeLists.txt | 5 +++++ experiments/core/reply_test.cpp | 31 +++++++++++++++++++++++++++++++ experiments/core/terminal.hpp | 15 +++++++++++++++ experiments/shell/shell.cpp | 6 ++++++ 4 files changed, 57 insertions(+) create mode 100644 experiments/core/reply_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 39a3699e..2b094bb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,10 @@ add_executable(decawm_mode_test experiments/core/decawm_mode_test.cpp) target_link_libraries(decawm_mode_test PRIVATE termforge_core) termforge_warnings(decawm_mode_test) +add_executable(reply_test experiments/core/reply_test.cpp) +target_link_libraries(reply_test PRIVATE termforge_core) +termforge_warnings(reply_test) + add_executable(order_query_test experiments/core/order_query_test.cpp) target_link_libraries(order_query_test PRIVATE termforge_core) termforge_warnings(order_query_test) @@ -319,6 +323,7 @@ if(BUILD_TESTING) add_test(NAME buffer_state_core COMMAND buffer_state_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) +add_test(NAME reply_core COMMAND reply_test) set_tests_properties(decawm_mode_core PROPERTIES TIMEOUT 10) add_test(NAME order_query_core COMMAND order_query_test) set_tests_properties(order_query_core PROPERTIES TIMEOUT 10) diff --git a/experiments/core/reply_test.cpp b/experiments/core/reply_test.cpp new file mode 100644 index 00000000..3e7b8654 --- /dev/null +++ b/experiments/core/reply_test.cpp @@ -0,0 +1,31 @@ +#include "terminal.hpp" +#include +#include +#include + +namespace { +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +} // namespace + +int main() { + using termforge::ParseMode; + using termforge::Parser; + Parser parser(5, 10, 10, ParseMode::recovery); + require(parser.take_replies().empty(), "no replies before any query"); + parser.feed("ab"); + parser.feed("\x1b[6n"); + const auto replies = parser.take_replies(); + require(replies.size() == 1 && replies[0] == "\x1b[1;3R", "cursor report mismatch"); + require(parser.take_replies().empty(), "replies were not drained"); + parser.feed("\x1b[2;7H"); + parser.feed("\x1b[6n"); + const auto moved = parser.take_replies(); + require(moved.size() == 1 && moved[0] == "\x1b[2;7R", "moved cursor report mismatch"); + // An unimplemented report must stay unimplemented instead of being faked. + parser.feed("\x1b[5n"); + require(parser.take_replies().empty(), "unsupported report produced a reply"); + std::puts("cursor reply contracts passed"); + return 0; +} diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index bdf16c70..d5c4ac35 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -2,7 +2,10 @@ #pragma once #include "screen.hpp" #include +#include #include +#include +#include namespace termforge { enum class ParseMode { strict, recovery }; @@ -24,6 +27,8 @@ class Parser { uint32_t scalar = 0, minimum = 0; int remaining = 0; std::string encoded; + // Bounded replies for implemented queries; drained by the host after each feed. + std::vector pending_replies; void dispatch(unsigned char final) { if (private_csi) { if (params.size() != 1 || (params[0] != 1049 && params[0] != 7) || (final != 'h' && final != 'l')) @@ -59,6 +64,15 @@ class Parser { case 'J': screen.erase_display(params[0]); break; case 'K': screen.erase_line(params[0]); break; case 'X': screen.erase_characters(n); break; + case 'n': + // Device status report 6 reports the 1-based cursor position. Other + // reports are not implemented and stay unsupported rather than faked. + if (params.size() == 1 && params[0] == 6) { + pending_replies.push_back("\x1b[" + std::to_string(screen.row + 1) + ";" + + std::to_string(screen.column + 1) + "R"); + return; + } + throw InputError("unsupported CSI command"); default: throw InputError("unsupported CSI command"); } } @@ -225,6 +239,7 @@ class Parser { Parser(int rows = 24, int columns = 80, size_t history = 100, ParseMode selected = ParseMode::strict) : mode(selected), screen(rows, columns, history) {} const RecoveryDiagnostics& diagnostics() const { return stats; } + std::vector take_replies() { return std::exchange(pending_replies, {}); } 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); diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 08d64285..866fb0a8 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -867,6 +867,12 @@ class ShellWindow { trace_offset_ += bytes.size(); } parser_.feed(bytes); batch += bytes.size(); +#ifndef TERMFORGE_CANDIDATE_CORE + // Query replies (for example a cursor position report) go back to the + // same bounded input queue as keyboard bytes, so ordering and + // backpressure stay identical for the child. + for (const auto& reply : parser_.take_replies()) input_.send(reply); +#endif #ifdef TERMFORGE_CANDIDATE_CORE route_terminal_events(); #endif From 2ab82f1c925737ff95262edb2fd7be850cb01208 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:49:53 +0800 Subject: [PATCH 2/3] test(core): drop CSI 6n from the ignored-sequence expectation refs #28 The recovery probe counts handled reports separately now, so the unknown-CSI case expects two ignored sequences and a new case pins the cursor report as handled. --- experiments/core/test_recovery.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/experiments/core/test_recovery.py b/experiments/core/test_recovery.py index 516cea65..f0bba2ca 100644 --- a/experiments/core/test_recovery.py +++ b/experiments/core/test_recovery.py @@ -135,7 +135,12 @@ def test_both_real_more_captures_keep_their_complete_bytes_and_expected_ech_resu self.assertFalse(value["screen"]["wrap_pending"]) def test_unknown_csi_consumes_private_and_intermediate(self): - self.expect(b"A\x1b[?9999h\x1b[1 q\x1b[6nZ", "AZ", stats={"ignored_sequences": 3}) + # CSI 6n is a handled cursor report now, so only the private and the + # intermediate forms count as ignored here. + self.expect(b"A\x1b[?9999h\x1b[1 q\x1b[6nZ", "AZ", stats={"ignored_sequences": 2}) + + def test_cursor_report_is_handled_not_ignored(self): + self.expect(b"A\x1b[6nZ", "AZ", stats={"ignored_sequences": 0}) def test_known_alternate_mode_is_not_counted_as_ignored(self): self.expect(b"A\x1b[?1049hALT\x1b[?1049hNEW\x1b[?1049lZ", "AZ", From 1bc2558d02be6b036adc1cf701226cd6411a986d Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:53:47 +0800 Subject: [PATCH 3/3] test(differential): treat the cursor query as a supported capability refs #28 The core answers CSI 6n now, so the matrix compares those records instead of marking them unsupported and the expected counts move five cases to match. --- experiments/differential/capability_matrix.py | 4 +++- experiments/differential/test_capability_matrix.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/experiments/differential/capability_matrix.py b/experiments/differential/capability_matrix.py index 25770eb6..8f1572f0 100644 --- a/experiments/differential/capability_matrix.py +++ b/experiments/differential/capability_matrix.py @@ -23,7 +23,9 @@ def data(self): case=Case("alternate_screen", "A\r\nB\x1b[?1049hALT\x1b[?1049lZ")), dict(name="scroll_region", supported=False, case=Case("scroll_region", "A\r\nB\r\nC\r\nD\x1b[2;3r\x1b[3;1H\nX\x1b[r\x1b[1;1H")), - dict(name="cursor_query", supported=False, + # The product core answers CSI 6n now, so this is a supported capability and + # its records are compared instead of being written off as unsupported. + dict(name="cursor_query", supported=True, case=Case("cursor_query", "A\x1b[6n")), dict(name="recovery_stream", supported=True, arguments=("--recovery",), case=RawCase("recovery_stream", "A\x1b[?9999zB\x1b]0;title\x07C\xffD")), diff --git a/experiments/differential/test_capability_matrix.py b/experiments/differential/test_capability_matrix.py index 3717d23f..8871d4ab 100644 --- a/experiments/differential/test_capability_matrix.py +++ b/experiments/differential/test_capability_matrix.py @@ -31,18 +31,22 @@ def records(self, name): def test_extended_matrix_preserves_execution_and_support_boundaries(self): self.assertTrue(self.summary["execution_passed"], self.summary["counts"]) self.assertEqual(self.summary["runs"], 25) - self.assertEqual(self.summary["counts"], {"core_unsupported": 10, "match": 6, + self.assertEqual(self.summary["counts"], {"core_unsupported": 5, "match": 11, "reference_difference": 4, "resize_difference": 5}) self.assertFalse(self.summary["semantic_acceptance"]) self.assertTrue(self.summary["resize_covered"]) self.assertFalse(self.summary["real_application_covered"]) - for name in ("scroll_region", "cursor_query"): + for name in ("scroll_region",): for record in self.records(name): self.assertEqual(record["status"], "core_unsupported") self.assertFalse(record["expected_core_support"]) self.assertIn("trace failed after checkpoint", (self.artifacts / "suite" / name / record["plan"].replace(":", "-") / "core.stderr.txt").read_text()) + for record in self.records("cursor_query"): + # The core answers the cursor report now, so this is a compared case. + self.assertTrue(record["expected_core_support"]) + self.assertEqual(record["status"], "match") def test_alternate_query_and_region_have_real_candidate_results(self): for record in self.records("alternate_screen"):