From 71619934fc01cbb30192f36f3bd3390dc0cdd404 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 10 Aug 2026 23:47:09 -0700 Subject: [PATCH] Fix USB terminal backspace handling --- examples/companion_radio/main.cpp | 34 +++++++++++++++++-- src/helpers/CLICommandUtils.h | 14 ++++++++ .../test_cli_command_utils.cpp | 18 ++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 25e47a69..87632741 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -139,6 +139,35 @@ static void clearUsbTerminalLine() { usb_terminal_line_len = 0; } +static void printUsbTerminalInputEcho() { + const char* password = nullptr; + size_t visible_len = usb_terminal_line_len; + if (mesh::cli::parseTerminalArgumentCommand( + usb_terminal_line, "login", password) + == mesh::cli::TerminalArgumentCommandMatch::Valid) { + visible_len = static_cast(password - usb_terminal_line); + } + + if (visible_len > 0) { + Serial.write(reinterpret_cast(usb_terminal_line), + visible_len); + } + for (size_t i = visible_len; i < usb_terminal_line_len; i++) { + Serial.print('*'); + } +} + +static void redrawUsbTerminalInput() { + // The documented picocom `--imap spchex` converts an echoed BS to "[08]". + // It deliberately leaves CR untouched, so redraw the edited line with CR + // and printable bytes only. Padding clears a removed tab or wide glyph. + Serial.print("\r> "); + printUsbTerminalInputEcho(); + Serial.print(" "); + Serial.print("\r> "); + printUsbTerminalInputEcho(); +} + static bool isUsbTerminalDataConnected() { #if defined(RP2040_PLATFORM) return (bool)Serial; @@ -285,8 +314,9 @@ static void serviceUsbTerminal() { if (c == '\b' || c == 0x7F) { if (usb_terminal_line_len > 0) { - usb_terminal_line[--usb_terminal_line_len] = 0; - Serial.print("\b \b"); + usb_terminal_line_len = mesh::cli::eraseLastTerminalInput( + usb_terminal_line, usb_terminal_line_len); + redrawUsbTerminalInput(); } continue; } diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 869ecbf8..b8ced260 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -118,6 +118,20 @@ inline bool shouldMaskTerminalInput(const char* line) { == TerminalArgumentCommandMatch::Valid; } +// Remove one complete UTF-8 code point from a terminal input buffer. Invalid +// trailing bytes are still removed safely, and the result remains terminated. +inline size_t eraseLastTerminalInput(char* line, size_t line_length) { + if (line == nullptr || line_length == 0) return 0; + + size_t new_length = line_length - 1; + while (new_length > 0 + && (static_cast(line[new_length]) & 0xC0) == 0x80) { + new_length--; + } + line[new_length] = 0; + return new_length; +} + inline TerminalChannelCommandMatch parseTerminalChannelMessage( const char* command, TerminalChannelMessage& message) { message.selector = nullptr; diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index da4fcf95..60f7aa4b 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -198,6 +198,24 @@ TEST(CLICommandUtils, MasksOnlyTerminalLoginPasswordInput) { EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login-status")); } +TEST(CLICommandUtils, BackspaceErasesAsciiAndUtf8Characters) { + char ascii[] = "trace path 2 7773"; + size_t length = strlen(ascii); + + length = mesh::cli::eraseLastTerminalInput(ascii, length); + EXPECT_STREQ("trace path 2 777", ascii); + EXPECT_EQ(strlen(ascii), length); + + char utf8[] = "channel Public hi 👋"; + length = strlen(utf8); + length = mesh::cli::eraseLastTerminalInput(utf8, length); + EXPECT_STREQ("channel Public hi ", utf8); + EXPECT_EQ(strlen(utf8), length); + + EXPECT_EQ(0u, mesh::cli::eraseLastTerminalInput(nullptr, 0)); + EXPECT_EQ(0u, mesh::cli::eraseLastTerminalInput(utf8, 0)); +} + TEST(CLICommandUtils, ParsesStrictDecimalValues) { float value = 0.0f;