Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions experiments/core/reply_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#include "terminal.hpp"
#include <cstdio>
#include <stdexcept>
#include <string>

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;
}
15 changes: 15 additions & 0 deletions experiments/core/terminal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
#pragma once
#include "screen.hpp"
#include <limits>
#include <string>
#include <string_view>
#include <utility>
#include <vector>

namespace termforge {
enum class ParseMode { strict, recovery };
Expand All @@ -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<std::string> pending_replies;
void dispatch(unsigned char final) {
if (private_csi) {
if (params.size() != 1 || (params[0] != 1049 && params[0] != 7) || (final != 'h' && final != 'l'))
Expand Down Expand Up @@ -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");
}
}
Expand Down Expand Up @@ -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<std::string> 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);
Expand Down
7 changes: 6 additions & 1 deletion experiments/core/test_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion experiments/differential/capability_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
8 changes: 6 additions & 2 deletions experiments/differential/test_capability_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
6 changes: 6 additions & 0 deletions experiments/shell/shell.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +870 to +874

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Route cursor replies through the Qt session host

In the confirmed C++/Qt/fixed-ConPTY product configuration, src/qt/terminal_session.hpp::poll() feeds this same Parser but never calls take_replies(); the only reply consumer added here is in the legacy Win32 shell. Consequently, a child issuing CSI 6n in the Qt application still receives no response, and repeated queries now accumulate reply strings indefinitely in the session's parser. Drain and send replies from the Qt session's output-consumption path as well, with an explicit policy for input rejection. This also follows AGENTS.md's requirement to implement the confirmed Qt product route.

AGENTS.md reference: AGENTS.md:L5-L6

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle rejected replies instead of silently discarding them

When a query arrives during an active paste or while the input queue is full, InputQueue::send() returns false (experiments/shell/queues.hpp explicitly rejects both cases). This loop ignores that result after take_replies() has removed the replies from the parser, so the cursor report is permanently lost and a child awaiting it can stall or time out. The queue therefore provides rejection rather than the claimed backpressure here. Retain rejected replies for later delivery or explicitly surface the failure instead of silently continuing.

Useful? React with 👍 / 👎.

#endif
#ifdef TERMFORGE_CANDIDATE_CORE
route_terminal_events();
#endif
Expand Down