diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ac6219d..b610e477 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,10 @@ add_executable(alternate_mode_test experiments/core/alternate_mode_test.cpp) target_link_libraries(alternate_mode_test PRIVATE termforge_core) termforge_warnings(alternate_mode_test) +add_executable(private_mode_test experiments/core/private_mode_test.cpp) +target_link_libraries(private_mode_test PRIVATE termforge_core) +termforge_warnings(private_mode_test) + 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) @@ -340,6 +344,7 @@ if(BUILD_TESTING) add_test(NAME view_mapping_core COMMAND view_mapping_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) add_test(NAME decawm_mode_core COMMAND decawm_mode_test) +add_test(NAME private_mode_core COMMAND private_mode_test) add_test(NAME decom_origin_core COMMAND decom_origin_test) add_test(NAME reply_core COMMAND reply_test) add_test(NAME edit_csi_core COMMAND edit_csi_test) diff --git a/experiments/core/private_mode_test.cpp b/experiments/core/private_mode_test.cpp new file mode 100644 index 00000000..1057a570 --- /dev/null +++ b/experiments/core/private_mode_test.cpp @@ -0,0 +1,88 @@ +// Private modes and window operations that real ConPTY sessions emit. +// +// The core used to throw on every private mode except ?1/?7/?25/?1049 and on +// every CSI final outside its draw set, so a captured native-viewport stream +// could not be replayed in strict mode at all. These cases pin the accepted +// set, the no-op semantics, and the sequences that remain unsupported. +#include "terminal.hpp" +#include +#include +#include +#include + +using namespace termforge; + +static void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static std::string replay(const std::string& bytes, int rows = 5, int columns = 20) { + Parser parser(rows, columns, 10); + parser.feed(bytes); + parser.finish(); + std::ostringstream out; + parser.screen.json(out); + return out.str(); +} + +static void check_rejected(const std::string& bytes, const char* message) { + try { + Parser parser(5, 20, 10); + parser.feed(bytes); + parser.finish(); + } catch (const std::exception&) { + return; + } + throw std::runtime_error(message); +} + +static void focus_and_win32_input_mode_are_accepted_without_effect() { + const std::string plain = replay("HELLO"); + // Both modes are requested by ConPTY itself; they must not affect drawing. + check(replay("\x1b[?1004hHELLO") == plain, "focus reporting changed the screen"); + check(replay("\x1b[?1004lHELLO") == plain, "focus reporting reset changed the screen"); + check(replay("\x1b[?9001hHELLO") == plain, "Win32 input mode changed the screen"); + check(replay("\x1b[?9001lHELLO") == plain, "Win32 input mode reset changed the screen"); +} + +static void window_commands_are_ignored_but_queries_are_not() { + const std::string plain = replay("HELLO"); + check(replay("\x1b[1tHELLO") == plain, "window de-iconify changed the screen"); + check(replay("\x1b[2tHELLO") == plain, "window iconify changed the screen"); + check(replay("\x1b[3tHELLO") == plain, "window move changed the screen"); +} + +static void device_attributes_claim_only_vt102() { + // The reply states what this terminal claims to support, so it stays at the + // level the core implements and matches the reference candidate. + Parser parser(5, 20, 10); + parser.feed("\x1b[c"); + parser.finish(); + const auto replies = parser.take_replies(); + check(replies.size() == 1 && replies[0] == "\x1b[?6c", "device attributes reply changed"); +} + +static void unsupported_sequences_still_fail_loudly() { + // Bracketed paste is not implemented, so enabling it must not be accepted + // silently as if the terminal supported it. + check_rejected("\x1b[?2004h", "bracketed paste must remain unsupported"); + check_rejected("\x1b[?1000h", "mouse reporting must remain unsupported"); + // CSI t beyond the iconify/move commands are queries; an unanswered query + // must stay a loud failure so it cannot look like a satisfied request. + check_rejected("\x1b[4t", "window pixel-size query must remain unsupported"); + check_rejected("\x1b[11t", "window state query must remain unsupported"); +} + +int main() { + try { + focus_and_win32_input_mode_are_accepted_without_effect(); + window_commands_are_ignored_but_queries_are_not(); + device_attributes_claim_only_vt102(); + unsupported_sequences_still_fail_loudly(); + std::cout << "private mode checks passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} \ No newline at end of file diff --git a/experiments/core/reply_test.cpp b/experiments/core/reply_test.cpp index a30de673..1e15a8d1 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"); + // DA1 answers only on the strict path. The product recovery path must stay + // silent because the fixed ConPTY emits DA1 in its startup packet and a + // reply sent back through the bounded input path at startup races with the + // session lifecycle (PR #272). + parser.feed("\x1b[c"); + require(parser.take_replies().empty(), "recovery DA1 produced a reply"); + { + Parser strict_parser(3, 10, 10, ParseMode::strict); + strict_parser.feed("\x1b[c"); + const auto da1 = strict_parser.take_replies(); + require(da1.size() == 1 && da1[0] == "\x1b[?6c", "strict DA1 reply mismatch"); + } // DECCKM and DECTCEM are state-only modes; strict parsing must accept them. { Parser strict_parser(3, 10, 10, ParseMode::strict); diff --git a/experiments/core/terminal.hpp b/experiments/core/terminal.hpp index 4048c7f6..b8a67851 100644 --- a/experiments/core/terminal.hpp +++ b/experiments/core/terminal.hpp @@ -46,6 +46,12 @@ class Parser { screen.set_origin_mode(final == 'h'); return; } + if (params.size() == 1 && (params[0] == 1004 || params[0] == 9001) && (final == 'h' || final == 'l')) + // Real ConPTY sessions request focus reporting (?1004) and Win32 + // input mode (?9001). Neither changes what this core draws or + // sends, and the host has nothing to act on yet, so they are + // accepted without being recorded. + 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) { @@ -84,6 +90,15 @@ class Parser { params.size() < 2 || params[1] == 0 ? -1 : params[1] - 1); break; case 'M': screen.delete_lines(n); break; + case 't': + // CSI 1/2/3 t are window commands (de-iconify, iconify, move) that + // ConPTY emits around a native resize. Acting on them would fight + // the host's own geometry, so they are accepted and ignored. Every + // other CSI t is a query this core cannot answer and stays + // unsupported rather than swallowing a request the application is + // waiting on. + if (params.size() == 1 && params[0] >= 1 && params[0] <= 3) return; + throw InputError("unsupported CSI command"); case 'X': screen.erase_characters(n); break; case '@': screen.insert_characters(n); break; case 'P': screen.delete_characters(n); break; @@ -99,6 +114,20 @@ class Parser { return; } throw InputError("unsupported CSI command"); + case 'c': + // Primary device attributes. Only the strict path (replay/tests) + // answers, at the VT102 level the core actually implements (cursor + // addressing, erase, insert/delete line and character, SGR, DECAWM, + // alternate screen), matching the reference candidate's \x1b[?6c. + // The product recovery path must stay silent: the fixed ConPTY + // emits DA1 once in its startup packet, and a reply bounced back + // through the bounded input path at startup races with the session + // lifecycle (PR #272). + if (mode == ParseMode::strict && params.size() == 1 && params[0] == 0) { + pending_replies.push_back("\x1b[?6c"); + return; + } + throw InputError("unsupported CSI command"); default: throw InputError("unsupported CSI command"); } } diff --git a/src/qt/terminal_session.hpp b/src/qt/terminal_session.hpp index a6093b45..e75d0d1d 100644 --- a/src/qt/terminal_session.hpp +++ b/src/qt/terminal_session.hpp @@ -156,8 +156,14 @@ class SessionModel final : public QObject { parser_.feed(bytes); // Query replies (a cursor position report today) go back to the // child through the same bounded input path as keystrokes. - for (const auto& reply : parser_.take_replies()) + // A reply only matters while the child is still attached: ConPTY + // emits DA1 once in its startup packet, so a session closed before + // its first poll would otherwise record a fatal "terminal input + // is unavailable" error for a reply nobody can receive. + for (const auto& reply : parser_.take_replies()) { + if (!running()) break; send(QByteArray(reply.data(), static_cast(reply.size()))); + } } catch (const std::exception& error) { parser_failed_ = true; remember_error(QString::fromUtf8(error.what()));