Repository: MidnightCommander/mc
Pull request #4663: Fix kitty keyboard protocol parsing of ctrl-o
Author: login: krobelus
Labels: name: area: tty
description: Interaction with the terminal, screen libraries
name: prio: high
description: Serious problem that could block progress
name: ver: 4.8.33
description: Reproducible in version 4.8.33
Created: 2025-03-05T13:15:40Z | Merged: 2025-03-08T10:37:54Z

The third commit wants to add more logic and a unit test related to decoding
terminal escape sequences and the kitty keyboard protocol.  It's probably
not right to keep all of this in lib/util.c, so extract a new module.

I called it "terminal" as first approximation - the name could be workshopped,
especially since we have a lot of terminal-specific code all around. We also
have lib/tty/tty.h though that's slightly different.

---

Commit 6e4510b55 (subshell: recognize CSI u encoding for ctrl-o, 2024-10-09)
made us parse sequences like \e[111;5u (meaning ctrl-o).  As reported in
https://github.com/fish-shell/fish-shell/issues/10640#issuecomment-2698350151,
the kitty keyboard protocol specifies various other sequences that also
mean ctrl-o. Specifically, when numlock is active.

Instead of matching raw byte values, let's add teach our ECMA-48 CSI parser to
decode parameter bytes and interpret them according to the kitty keyboard protocol.

Tested with fish version 4:

	1. Turn on numlock
	2. SHELL=/bin/fish mc
	3. ctrl-o
	4. Enter
	5. ctrl-o -- confirmed that this one is recognized now

Note that capslock still disables ctrl-o. We could change that easily
(and for consistency also allow ctrl-O).

While at it, add some unit tests (using a fish prompt as input data).



Diff:
diff --git a/lib/Makefile.am b/lib/Makefile.am
index 9e0bbbb775..060e7175da 100644
--- a/lib/Makefile.am
+++ b/lib/Makefile.am
@@ -26,6 +26,7 @@ SUBLIB_includes = \
 	widget.h
 
 SRC_mc_utils = \
+	terminal.c terminal.h \
 	utilunix.c \
 	unixcompat.h \
 	util.c util.h
diff --git a/lib/terminal.c b/lib/terminal.c
new file mode 100644
index 0000000000..e3589a55f2
--- /dev/null
+++ b/lib/terminal.c
@@ -0,0 +1,318 @@
+/*
+   Terminal emulation.
+
+   Copyright (C) 2025
+   Free Software Foundation, Inc.
+
+   This file is part of the Midnight Commander.
+   Authors:
+   Miguel de Icaza, 1994, 1995, 1996
+   Johannes Altmanninger, 2025
+
+   The Midnight Commander is free software: you can redistribute it
+   and/or modify it under the terms of the GNU General Public License as
+   published by the Free Software Foundation, either version 3 of the License,
+   or (at your option) any later version.
+
+   The Midnight Commander is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/** \file terminal.c
+ *  \brief Source: terminal emulation.
+ *  \author Johannes Altmanninger
+ *  \date 2025
+ *
+ *  Subshells running inside Midnight Commander may assume they run inside
+ *  a terminal. This module helps us act like a real terminal in relevant
+ *  aspects.
+ */
+
+#include <config.h>
+
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+#include "lib/terminal.h"
+
+/*** global variables ****************************************************************************/
+
+/*** file scope macro definitions ****************************************************************/
+
+/*** file scope type declarations ****************************************************************/
+
+/*** forward declarations (file scope functions) *************************************************/
+
+/* --------------------------------------------------------------------------------------------- */
+/*** file scope functions ************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/* --------------------------------------------------------------------------------------------- */
+/*** public functions ****************************************************************************/
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+
+    if (c >= '<' && c <= '?')  // "<=>?"
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+
+    if (private_mode != '\0')
+    {
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    }
+    else
+    {
+        if (out != NULL)
+            // N.B. empty parameter strings are allowed. For our current use,
+            // treating them as zeroes happens to work.
+            memset (out->params, 0, sizeof (out->params));
+
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
+                    goto invalid_sequence;  // overflow
+                tmp *= 10;
+                if (tmp + c - '0' < tmp)
+                    goto invalid_sequence;  // overflow
+                tmp += c - '0';
+                if (out != NULL)
+                    out->params[param_count - 1][sub_index] = tmp;
+            }
+            else if (c == ':' && ++sub_index < G_N_ELEMENTS (out->params[0]))
+                tmp = 0;
+            else if (c == ';' && ++param_count <= G_N_ELEMENTS (out->params))
+                tmp = 0, sub_index = 0;
+            else
+                goto invalid_sequence;
+            NEXT_CHAR;
+        }
+    }
+
+    while (c >= 0x20 && c <= 0x2F)  // intermediate bytes
+        NEXT_CHAR;
+#undef NEXT_CHAR
+
+    if (c < 0x40 || c > 0x7E)  // final byte
+        goto invalid_sequence;
+
+    ++s;
+    ok = TRUE;
+
+    if (out != NULL)
+    {
+        out->private_mode = private_mode;
+        out->param_count = param_count;
+    }
+
+invalid_sequence:
+    *sptr = s;
+    return ok;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * Remove all control sequences (CSI, OSC) from the argument string.
+ *
+ * The 256-color and true-color escape sequences should allow either ';' or ':' inside as
+ * separator, actually, ':' is the more correct according to ECMA-48. Some terminal emulators
+ * (e.g. xterm, gnome-terminal) support this.
+ *
+ * Non-printable characters are also removed.
+ */
+
+char *
+strip_ctrl_codes (char *s)
+{
+    char *w;        // Current position where the stripped data is written
+    const char *r;  // Current position where the original data is read
+
+    if (s == NULL)
+        return NULL;
+
+    const char *end = s + strlen (s);
+
+    for (w = s, r = s; *r != '\0';)
+    {
+        if (*r == ESC_CHAR)
+        {
+            // Skip the control sequence's arguments
+            // '(' need to avoid strange 'B' letter in *Suse (if mc runs under root user)
+            if (*(++r) == '[' || *r == '(')
+            {
+                ++r;
+                parse_csi (NULL, &r, end);
+                // We're already past the sequence, no need to increment.
+                continue;
+            }
+            if (*r == ']')
+            {
+                /*
+                 * Skip xterm's OSC (Operating System Command)
+                 * https://www.xfree86.org/current/ctlseqs.html
+                 * OSC P s ; P t ST
+                 * OSC P s ; P t BEL
+                 */
+                const char *new_r;
+
+                for (new_r = r; *new_r != '\0'; new_r++)
+                {
+                    switch (*new_r)
+                    {
+                        // BEL
+                    case '\a':
+                        r = new_r;
+                        goto osc_out;
+                    case ESC_CHAR:
+                        // ST
+                        if (new_r[1] == '\\')
+                        {
+                            r = new_r + 1;
+                            goto osc_out;
+                        }
+                        break;
+                    default:
+                        break;
+                    }
+                }
+            osc_out:;
+            }
+
+            /*
+             * Now we are at the last character of the sequence.
+             * Skip it unless it's binary 0.
+             */
+            if (*r != '\0')
+                r++;
+        }
+        else
+        {
+            const char *n;
+
+            n = str_cget_next_char (r);
+            if (str_isprint (r))
+            {
+                memmove (w, r, n - r);
+                w += n - r;
+            }
+            r = n;
+        }
+    }
+
+    *w = '\0';
+    return s;
+}
+
+/* --------------------------------------------------------------------------------------------- */
+/**
+ * Convert "\E" -> esc character and ^x to control-x key and ^^ to ^ key
+ *
+ * @param p pointer to string
+ *
+ * @return newly allocated string
+ */
+
+char *
+convert_controls (const char *p)
+{
+    char *valcopy;
+    char *q;
+
+    valcopy = g_strdup (p);
+
+    // Parse the escape special character
+    for (q = valcopy; *p != '\0';)
+        switch (*p)
+        {
+        case '\\':
+            p++;
+
+            if (*p == 'e' || *p == 'E')
+            {
+                p++;
+                *q++ = ESC_CHAR;
+            }
+            break;
+
+        case '^':
+            p++;
+            if (*p == '^')
+                *q++ = *p++;
+            else
+            {
+                char c;
+
+                c = *p | 0x20;
+                if (c >= 'a' && c <= 'z')
+                {
+                    *q++ = c - 'a' + 1;
+                    p++;
+                }
+                else if (*p != '\0')
+                    p++;
+            }
+            break;
+
+        default:
+            *q++ = *p++;
+        }
+
+    *q = '\0';
+    return valcopy;
+}
+
+/* --------------------------------------------------------------------------------------------- */
diff --git a/lib/terminal.h b/lib/terminal.h
new file mode 100644
index 0000000000..bd0f79cb71
--- /dev/null
+++ b/lib/terminal.h
@@ -0,0 +1,41 @@
+/** \file terminal.h
+ *  \brief Header: terminal emulation logic.
+ */
+
+#ifndef MC_TERMINAL_H
+#define MC_TERMINAL_H
+
+#include <sys/types.h>
+#include <inttypes.h>  // uint32_t
+
+#include "lib/global.h"  // include <glib.h>
+
+/*** typedefs(not structures) and defined constants **********************************************/
+
+/*** enums ***************************************************************************************/
+
+/*** structures declarations (and typedefs of structures)*****************************************/
+
+struct csi_command_t
+{
+    char private_mode;
+    uint32_t params[16][4];
+    size_t param_count;
+};
+
+/*** global variables defined in .c file *********************************************************/
+
+/*** declarations of public functions ************************************************************/
+
+gboolean parse_csi (struct csi_command_t *out, const char **sptr, const char *end);
+
+char *strip_ctrl_codes (char *s);
+
+/* Replaces "\\E" and "\\e" with "\033". Replaces "^" + [a-z] with
+ * ((char) 1 + (c - 'a')). The same goes for "^" + [A-Z].
+ * Returns a newly allocated string. */
+char *convert_controls (const char *s);
+
+/*** inline functions ****************************************************************************/
+
+#endif
diff --git a/lib/util.c b/lib/util.c
index 88fde2afe0..76e4638bc7 100644
--- a/lib/util.c
+++ b/lib/util.c
@@ -741,105 +741,6 @@ skip_numbers (const char *s)
     return su;
 }
 
-/* --------------------------------------------------------------------------------------------- */
-/**
- * Remove all control sequences from the argument string.  We define
- * "control sequence", in a sort of pidgin BNF, as follows:
- *
- * control-seq = Esc non-'['
- *             | Esc '[' (parameter-byte)* (intermediate-byte)* final-byte
- * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
- * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
- * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
- *
- * The 256-color and true-color escape sequences should allow either ';' or ':' inside as separator,
- * actually, ':' is the more correct according to ECMA-48.
- * Some terminal emulators (e.g. xterm, gnome-terminal) support this.
- *
- * Non-printable characters are also removed.
- */
-
-char *
-strip_ctrl_codes (char *s)
-{
-    char *w;  // Current position where the stripped data is written
-    char *r;  // Current position where the original data is read
-
-    if (s == NULL)
-        return NULL;
-
-    for (w = s, r = s; *r != '\0';)
-    {
-        if (*r == ESC_CHAR)
-        {
-            // Skip the control sequence's arguments
-            // '(' need to avoid strange 'B' letter in *Suse (if mc runs under root user)
-            if (*(++r) == '[' || *r == '(')
-            {
-                // strchr() matches trailing binary 0
-                while (*(++r) != '\0' && strchr ("0123456789;:<=>?", *r) != NULL)
-                    ;
-                while (*r != '\0' && (*r < 0x40 || *r > 0x7E))
-                    ++r;
-            }
-            else if (*r == ']')
-            {
-                /*
-                 * Skip xterm's OSC (Operating System Command)
-                 * https://www.xfree86.org/current/ctlseqs.html
-                 * OSC P s ; P t ST
-                 * OSC P s ; P t BEL
-                 */
-                char *new_r;
-
-                for (new_r = r; *new_r != '\0'; new_r++)
-                {
-                    switch (*new_r)
-                    {
-                        // BEL
-                    case '\a':
-                        r = new_r;
-                        goto osc_out;
-                    case ESC_CHAR:
-                        // ST
-                        if (new_r[1] == '\\')
-                        {
-                            r = new_r + 1;
-                            goto osc_out;
-                        }
-                        break;
-                    default:
-                        break;
-                    }
-                }
-            osc_out:;
-            }
-
-            /*
-             * Now we are at the last character of the sequence.
-             * Skip it unless it's binary 0.
-             */
-            if (*r != '\0')
-                r++;
-        }
-        else
-        {
-            char *n;
-
-            n = str_get_next_char (r);
-            if (str_isprint (r))
-            {
-                memmove (w, r, n - r);
-                w += n - r;
-            }
-            r = n;
-        }
-    }
-
-    *w = '\0';
-    return s;
-}
-
 /* --------------------------------------------------------------------------------------------- */
 
 enum compression_type
@@ -977,64 +878,6 @@ wipe_password (char *passwd)
     }
 }
 
-/* --------------------------------------------------------------------------------------------- */
-/**
- * Convert "\E" -> esc character and ^x to control-x key and ^^ to ^ key
- *
- * @param p pointer to string
- *
- * @return newly allocated string
- */
-
-char *
-convert_controls (const char *p)
-{
-    char *valcopy;
-    char *q;
-
-    valcopy = g_strdup (p);
-
-    // Parse the escape special character
-    for (q = valcopy; *p != '\0';)
-        switch (*p)
-        {
-        case '\\':
-            p++;
-
-            if (*p == 'e' || *p == 'E')
-            {
-                p++;
-                *q++ = ESC_CHAR;
-            }
-            break;
-
-        case '^':
-            p++;
-            if (*p == '^')
-                *q++ = *p++;
-            else
-            {
-                char c;
-
-                c = *p | 0x20;
-                if (c >= 'a' && c <= 'z')
-                {
-                    *q++ = c - 'a' + 1;
-                    p++;
-                }
-                else if (*p != '\0')
-                    p++;
-            }
-            break;
-
-        default:
-            *q++ = *p++;
-        }
-
-    *q = '\0';
-    return valcopy;
-}
-
 /* --------------------------------------------------------------------------------------------- */
 /**
  * Finds out a relative path from first to second, i.e. goes as many ..
diff --git a/lib/util.h b/lib/util.h
index 1df2070fbc..87505f3f1c 100644
--- a/lib/util.h
+++ b/lib/util.h
@@ -191,12 +191,6 @@ const char *extension (const char *);
 const char *unix_error_string (int error_num);
 const char *skip_separators (const char *s);
 const char *skip_numbers (const char *s);
-char *strip_ctrl_codes (char *s);
-
-/* Replaces "\\E" and "\\e" with "\033". Replaces "^" + [a-z] with
- * ((char) 1 + (c - 'a')). The same goes for "^" + [A-Z].
- * Returns a newly allocated string. */
-char *convert_controls (const char *s);
 
 /* overwrites passwd with '\0's and frees it. */
 void wipe_password (char *passwd);
diff --git a/src/filemanager/layout.c b/src/filemanager/layout.c
index 21db1420a4..40aeed79b6 100644
--- a/src/filemanager/layout.c
+++ b/src/filemanager/layout.c
@@ -41,6 +41,7 @@
 #include <unistd.h>
 
 #include "lib/global.h"
+#include "lib/terminal.h"  // strip_ctrl_codes()
 #include "lib/tty/tty.h"
 #include "lib/skin.h"
 #include "lib/tty/key.h"
diff --git a/src/learn.c b/src/learn.c
index cc06ddf856..d19e7bc461 100644
--- a/src/learn.c
+++ b/src/learn.c
@@ -38,7 +38,8 @@
 #include "lib/tty/key.h"
 #include "lib/mcconfig.h"
 #include "lib/strutil.h"
-#include "lib/util.h"  // convert_controls()
+#include "lib/terminal.h"  // convert_controls()
+#include "lib/util.h"      // MC_PTR_FREE
 #include "lib/widget.h"
 
 #include "setup.h"
diff --git a/src/setup.c b/src/setup.c
index 2c8ed4866b..ec4b4c24af 100644
--- a/src/setup.c
+++ b/src/setup.c
@@ -38,6 +38,7 @@
 #include "lib/tty/key.h"
 #include "lib/mcconfig.h"  // num_history_items_recorded
 #include "lib/fileloc.h"
+#include "lib/terminal.h"  // convert_controls()
 #include "lib/timefmt.h"
 #include "lib/util.h"
 
diff --git a/src/subshell/common.c b/src/subshell/common.c
index f4a4a9c7b9..994e490bd9 100644
--- a/src/subshell/common.c
+++ b/src/subshell/common.c
@@ -98,6 +98,7 @@
 #include "lib/global.h"
 
 #include "lib/fileloc.h"
+#include "lib/terminal.h"
 #include "lib/unixcompat.h"
 #include "lib/tty/tty.h"  // LINES
 #include "lib/tty/key.h"  // XCTRL
@@ -117,7 +118,7 @@
 
 /* State of the subshell:
  * INACTIVE: the default state; awaiting a command
- * ACTIVE: remain in the shell until the user hits 'subshell_switch_key'
+ * ACTIVE: remain in the shell until the user hits the subshell switch key
  * RUNNING_COMMAND: return to MC when the current command finishes */
 enum subshell_state_enum subshell_state;
 
@@ -177,12 +178,6 @@ static char tcsh_fifo[BUF_SMALL];
 
 static int subshell_pty_slave = -1;
 
-/* The key for switching back to MC from the subshell */
-static const char subshell_switch_key = XCTRL ('o') & 255;
-
-static const char subshell_switch_key_csi_u[] = "\x1b[111;5u";
-static const size_t subshell_switch_key_csi_u_len = sizeof (subshell_switch_key_csi_u) - 1;
-
 /* For reading/writing on the subshell's pty */
 static char pty_buffer[PTY_BUFFER_SIZE] = "\0";
 
@@ -466,7 +461,7 @@ init_subshell_child (const char *pty_name)
 
     case SHELL_FISH:
         execl (mc_global.shell->path, mc_global.shell->path, "--init-command",
-               "set --global __mc_csi_u 1", (char *) NULL);
+               "set --global __mc_kitty_keyboard 1", (char *) NULL);
         break;
 
     case SHELL_ASH_BUSYBOX:
@@ -778,6 +773,57 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+/* --------------------------------------------------------------------------------------------- */
+
+enum kitty_keyboard_protocol_t
+{
+    MODIFIER_SHIFT = 0x01,
+    MODIFIER_CTRL = 0x04,
+    MODIFIER_CAPS_LOCK = 0x40,
+    MODIFIER_NUM_LOCK = 0x80,
+
+    EVENT_TYPE_PRESS = 1,
+    EVENT_TYPE_REPEAT = 2
+};
+
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as per the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    buffer += 2;
+    len -= 2;
+
+    struct csi_command_t csi;
+
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+    if (csi.private_mode != '\0' || buffer[-1] != 'u')
+        return FALSE;
+    if (csi.param_count != 2)  // ctrl-o must have the modifier field
+        return FALSE;
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+
+    const uint32_t codepoint = csi.params[0][0];
+    const uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    if (event != 0 && event != EVENT_TYPE_PRESS && event != EVENT_TYPE_REPEAT)
+        return FALSE;
+
+    return codepoint == 'o'
+        && (modifiers & ~(MODIFIER_CAPS_LOCK | MODIFIER_NUM_LOCK)) == MODIFIER_CTRL;
+}
+
 /* --------------------------------------------------------------------------------------------- */
 /** Feed the subshell our keyboard input until it says it's finished */
 
@@ -907,11 +953,7 @@ feed_subshell (int how, gboolean fail_on_error)
             }
 
             for (i = 0; i < bytes; ++i)
-                if (pty_buffer[i] == subshell_switch_key
-                    || (subshell_switch_key_csi_u_len <= (size_t) bytes - i
-                        && memcmp (&pty_buffer[i], subshell_switch_key_csi_u,
-                                   subshell_switch_key_csi_u_len)
-                            == 0))
+                if (peek_subshell_switch_key (pty_buffer + i, bytes - i))
                 {
                     write_all (mc_global.tty.subshell_pty, pty_buffer, i);
 
diff --git a/tests/.gitignore b/tests/.gitignore
index ddf11e3a73..d3b3fece1c 100644
--- a/tests/.gitignore
+++ b/tests/.gitignore
@@ -53,6 +53,7 @@ lib/strutil/str_verscmp
 lib/strutil/str_verscmp.log
 lib/strutil/str_verscmp.trs
 lib/strutil/test-suite.log
+lib/terminal
 lib/test-suite.log
 lib/utilinux__my_system-fork_child.log
 lib/utilinux__my_system-fork_child_shell.log
diff --git a/tests/lib/Makefile.am b/tests/lib/Makefile.am
index 256b2d18d8..3ed6d1676f 100644
--- a/tests/lib/Makefile.am
+++ b/tests/lib/Makefile.am
@@ -19,6 +19,7 @@ TESTS = \
 	mc_build_filename \
 	name_quote \
 	serialize \
+	terminal \
 	tty \
 	utilunix__mc_pstream_get_string \
 	utilunix__my_system_fork_fail \
@@ -47,6 +48,9 @@ name_quote_SOURCES = \
 serialize_SOURCES = \
 	serialize.c
 
+terminal_SOURCES = \
+	terminal.c
+
 tty_SOURCES = \
 	tty.c
 
diff --git a/tests/lib/terminal.c b/tests/lib/terminal.c
new file mode 100644
index 0000000000..4eed2821e7
--- /dev/null
+++ b/tests/lib/terminal.c
@@ -0,0 +1,97 @@
+/*
+   lib/terminal - tests for terminal emulation functions
+
+   Copyright (C) 2013-2025
+   Free Software Foundation, Inc.
+
+   Written by:
+   Johannes Altmanninger, 2025
+
+   This file is part of the Midnight Commander.
+
+   The Midnight Commander is free software: you can redistribute it
+   and/or modify it under the terms of the GNU General Public License as
+   published by the Free Software Foundation, either version 3 of the License,
+   or (at your option) any later version.
+
+   The Midnight Commander is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#define TEST_SUITE_NAME "/lib/terminal"
+
+#include "tests/mctest.h"
+
+#include <string.h>
+
+#include "lib/global.h"  // include <glib.h>
+#include "lib/terminal.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+static void
+setup (void)
+{
+    str_init_strings (NULL);
+}
+
+static void
+teardown (void)
+{
+    str_uninit_strings ();
+}
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_parse_csi)
+{
+    const char *s = &"\x1b[=5uRest"[2];
+    const char *end = s + strlen (s);
+    gboolean ok = parse_csi (NULL, &s, end);
+    ck_assert_msg (ok, "failed to parse CSI");
+    ck_assert_str_eq (s, "Rest");
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+START_TEST (test_strip_ctrl_codes)
+{
+    char *s = strdup (
+        "\033]0;~\a\033[30m\033(B\033[m\033]133;A;special_key=1\a$ "
+        "\033[K\033[?2004h\033[>4;1m\033[=5u\033=\033[?2004l\033[>4;0m\033[=0u\033>\033[?2004h"
+        "\033[>4;1m\033[=5u\033=\033[?2004l\033[>4;0m\033[=0u\033>\033[?2004h\033[>4;1m\033[=5u"
+        "\033=");
+    char *actual = strip_ctrl_codes (s);
+    const char *expected = "$ ";
+    ck_assert_str_eq (actual, expected);
+    free (s);
+}
+END_TEST
+
+/* --------------------------------------------------------------------------------------------- */
+
+int
+main (void)
+{
+    TCase *tc_core;
+
+    tc_core = tcase_create ("Core");
+
+    tcase_add_checked_fixture (tc_core, setup, teardown);
+
+    // Add new tests here: ***************
+    tcase_add_test (tc_core, test_parse_csi);
+    tcase_add_test (tc_core, test_strip_ctrl_codes);
+    // ***********************************
+
+    return mctest_run_all (tc_core);
+}
+
+/* --------------------------------------------------------------------------------------------- */


Reviews:
user: login: zyv
body: Hey, thanks for a quality contribution featuring... a test!!!11 I'm not sure if you're the first ever, but you get a gold star anyway ⭐ 🌟 ⭐ 

I'm really unfamiliar with the code and have little idea about the subject, hopefully @aborodin can say something. I only left formal comments that can be easily addressed. Also, please fix the build on Solaris.

```
  Making check in lib
  make  terminal
    CC       terminal.o
  terminal.c: In function ‘test_strip_ctrl_codes’:
  terminal.c:56:1: error: ‘main’ is normally a non-static function [-Werror=main]
     56 | main (void)
        | ^~~~
  terminal.c:70:1: error: expected declaration or statement at end of input
     70 | }
        | ^
  At top level:
  terminal.c:56:1: error: ‘main’ defined but not used [-Werror=unused-function]
     56 | main (void)
        | ^~~~
```

I guess you missed the `END_TEST` macro.

@asl could you please report whether this helps?

> Note that capslock still disables ctrl-o. We could change that easily
> (and for consistency also allow ctrl-O).

I'm not sure if we generally recognize shifted stuff, and if we do, how it works. I'm afraid to open this can of worms. There is some information in #2150 ... @ossilator and @egmontkob know more about this.

P.S. How amazing was it to enjoy the privilege of using GitHub for issues and a pull-request submission interface?

I know I probably can't impress people with this, but you're the one who experienced our venerable Trac... oh man, that was a hell of a lot of work. I need some encouragement 😅 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-05T14:31:45Z
user: login: mc-worker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T18:26:29Z
user: login: krobelus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T21:15:51Z
user: login: krobelus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T21:16:12Z
user: login: zyv
body: I added a few fixes. Otherwise I'm happy with the code as it is (but see below).

> ok I made it so c-o still works when capslock is on.

I think I can agree with that...

> c-s-o works too because from a user perspective that's
> exactly the same thing as c-o with capslock on.
> Would need a good reason to treat them differently.

... but this definitely sounds wrong to me. In the GUI programs I use, the shortcuts with Shift definitely have a different meaning. That is, Ctrl-O and Ctrl-Shift-O are definitely different, and both work regardless of whether CapsLock is on or not.

Could you please change this back? I don't want to break anything, so I won't do it myself.

> range-diff

How did you do that? This could be useful to learn.

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-06T08:52:34Z
user: login: mc-worker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:22:58Z
user: login: zyv
body: Thank you, I'm happy when CI and Andrew are happy!

```
common.c: In function 'peek_subshell_switch_key':
common.c:814:20: error: unused variable 'shifted_key' [-Werror=unused-variable]
  814 |     const uint32_t shifted_key = csi.params[0][1];
      |                    ^~~~~~~~~~~
```
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T09:44:23Z

Review comments:
diff_hunk: @@ -0,0 +1 @@
+terminal
path: tests/src/lib/.gitignore
user: login: zyv
body: Please move this to `tests/.gitignore`.
created_at: 2025-03-05T14:03:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,23 @@
+PACKAGE_STRING = "/src/lib"
+
+AM_CPPFLAGS = \
+	-DTEST_SHARE_DIR=\"$(abs_srcdir)\" \
+	$(GLIB_CFLAGS) \
+	-I$(top_srcdir) \
+	@CHECK_CFLAGS@
+
+LIBS = @CHECK_LIBS@ \
+	$(top_builddir)/src/libinternal.la \
+	$(top_builddir)/lib/libmc.la
+
+if ENABLE_MCLIB
+LIBS += $(GLIB_LIBS)
+endif
+
+TESTS = \
+	terminal
+
+check_PROGRAMS = $(TESTS)
+
+edit_complete_word_cmd_SOURCES = \
path: tests/src/lib/Makefile.am
user: login: zyv
body: ```suggestion
terminal_SOURCES = \
```
created_at: 2025-03-05T14:03:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+#include <string.h>
path: tests/src/lib/terminal.c
user: login: zyv
body: Please add the header (see other test files).
created_at: 2025-03-05T14:04:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
path: lib/terminal.c
user: login: zyv
body: Please add the header.
created_at: 2025-03-05T14:05:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
+                    goto invalid_sequence;
+                tmp *= 10;
+                tmp += c - '0';
+                if (out)
+                    out->params[param_count - 1][sub_index] = tmp;
+            }
+            else if (c == ':' && ++sub_index < ARRAY_LEN (out->params[0]))
+                tmp = 0;
+            else if (c == ';' && ++param_count <= ARRAY_LEN (out->params))
+                tmp = sub_index = 0;
+            else
+                goto invalid_sequence;
+            NEXT_CHAR;
+        }
+    }
+
+    while (c >= 0x20 && c <= 0x2F)  // intermediate bytes
+        NEXT_CHAR;
+#undef NEXT_CHAR
+    if (c < 0x40 || c > 0x7E)  // final byte
+        goto invalid_sequence;
+    ++s;
+    ok = TRUE;
+    if (out)
+    {
+        out->private_mode = private_mode;
+        out->param_count = param_count;
+    }
+invalid_sequence:
+    *sptr = s;
+    return ok;
+}
+
+/* ---------------------------------------------------------------------------------------------
+ */
path: lib/terminal.c
user: login: zyv
body: ```suggestion
/* --------------------------------------------------------------------------------------------- */
```
created_at: 2025-03-05T14:06:25Z
author_association: MEMBER
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
path: src/subshell/common.c
user: login: zyv
body: Shouldn't this enum go to the header file? See e.g.

https://github.com/MidnightCommander/mc/blob/master/lib/widget/input.h
created_at: 2025-03-05T14:10:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+/** \file terminal.h
+ *  \brief Header: terminal command decoding. See also tty.h
+ */
+
path: lib/terminal.h
user: login: zyv
body: Please use the fancy header file template (see other files).
created_at: 2025-03-05T14:11:15Z
author_association: MEMBER
diff_hunk: @@ -719,6 +719,7 @@ tests/lib/widget/Makefile
 tests/src/Makefile
 tests/src/filemanager/Makefile
 tests/src/editor/Makefile
+tests/src/lib/Makefile
path: configure.ac
user: login: mc-worker
body: Why tests/src/lib? Since the file under test is in lib, test should be in tests/lib.
created_at: 2025-03-05T18:04:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,6 +28,7 @@ SUBLIB_includes = \
 SRC_mc_utils = \
 	utilunix.c \
 	unixcompat.h \
+	terminal.c terminal.h \
 	util.c util.h
 
path: lib/Makefile.am
user: login: mc-worker
body: Please keep the file list alphabetically sorted.
created_at: 2025-03-05T18:06:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
path: lib/terminal.c
user: login: mc-worker
body: Please use the template from maint/templates.
created_at: 2025-03-05T18:07:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
path: lib/terminal.c
user: login: mc-worker
body: Please move this include to the end of includes.
created_at: 2025-03-05T18:08:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
path: lib/terminal.c
user: login: mc-worker
body: Please use g_assert.
created_at: 2025-03-05T18:09:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
path: lib/terminal.c
user: login: mc-worker
body: Please use explicit comparison for char: `private_mode != '\0'`.
created_at: 2025-03-05T18:11:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
path: lib/terminal.c
user: login: mc-worker
body: Please use explicit comparison for pointer: `out != NULL`.
created_at: 2025-03-05T18:12:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
path: lib/terminal.c
user: login: mc-worker
body: What do you mean?
created_at: 2025-03-05T18:13:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
+                    goto invalid_sequence;
+                tmp *= 10;
+                tmp += c - '0';
+                if (out)
path: lib/terminal.c
user: login: mc-worker
body: Please use explicit comparison for pointer: `out != NULL`.
created_at: 2025-03-05T18:14:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
+                    goto invalid_sequence;
+                tmp *= 10;
+                tmp += c - '0';
+                if (out)
+                    out->params[param_count - 1][sub_index] = tmp;
+            }
+            else if (c == ':' && ++sub_index < ARRAY_LEN (out->params[0]))
+                tmp = 0;
+            else if (c == ';' && ++param_count <= ARRAY_LEN (out->params))
+                tmp = sub_index = 0;
+            else
+                goto invalid_sequence;
+            NEXT_CHAR;
+        }
+    }
+
+    while (c >= 0x20 && c <= 0x2F)  // intermediate bytes
+        NEXT_CHAR;
+#undef NEXT_CHAR
+    if (c < 0x40 || c > 0x7E)  // final byte
+        goto invalid_sequence;
+    ++s;
+    ok = TRUE;
+    if (out)
path: lib/terminal.c
user: login: mc-worker
body: Please use explicit comparison for pointer: `out != NULL`.
created_at: 2025-03-05T18:14:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
+                    goto invalid_sequence;
+                tmp *= 10;
+                tmp += c - '0';
+                if (out)
+                    out->params[param_count - 1][sub_index] = tmp;
+            }
+            else if (c == ':' && ++sub_index < ARRAY_LEN (out->params[0]))
+                tmp = 0;
+            else if (c == ';' && ++param_count <= ARRAY_LEN (out->params))
+                tmp = sub_index = 0;
+            else
+                goto invalid_sequence;
+            NEXT_CHAR;
+        }
+    }
+
+    while (c >= 0x20 && c <= 0x2F)  // intermediate bytes
+        NEXT_CHAR;
+#undef NEXT_CHAR
+    if (c < 0x40 || c > 0x7E)  // final byte
+        goto invalid_sequence;
+    ++s;
+    ok = TRUE;
+    if (out)
+    {
+        out->private_mode = private_mode;
+        out->param_count = param_count;
+    }
+invalid_sequence:
+    *sptr = s;
+    return ok;
+}
+
+/* ---------------------------------------------------------------------------------------------
+ */
+/**
+ * Remove all control sequences (CSI, OSC) from the argument string.
+ *
+ * The 256-color and true-color escape sequences should allow either ';' or ':' inside as
+ * separator, actually, ':' is the more correct according to ECMA-48. Some terminal emulators
+ * (e.g. xterm, gnome-terminal) support this.
+ *
+ * Non-printable characters are also removed.
+ */
+
+char *
+strip_ctrl_codes (char *s)
+{
+    char *w;        // Current position where the stripped data is written
+    const char *r;  // Current position where the original data is read
+
+    if (s == NULL)
+        return NULL;
+
+    for (w = s, r = s; *r != '\0';)
+    {
+        if (*r == ESC_CHAR)
+        {
+            // Skip the control sequence's arguments
+            // '(' need to avoid strange 'B' letter in *Suse (if mc runs under root user)
+            if (*(++r) == '[' || *r == '(')
+            {
+                ++r;
+                parse_csi (NULL, &r, r + strlen (r));
+                // We're already past the sequence, no need to increment.
+                continue;
+            }
+            else if (*r == ']')
path: lib/terminal.c
user: login: mc-worker
body: No need `else` after `continue`.
created_at: 2025-03-05T18:16:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,28 @@
+/** \file terminal.h
+ *  \brief Header: terminal command decoding. See also tty.h
+ */
+
+#ifndef MC_TERMINAL_H
+#define MC_TERMINAL_H
+
+#include <sys/types.h>
+#include <inttypes.h>  // uintmax_t
path: lib/terminal.h
user: login: mc-worker
body: uintmax_t is unused in this file, 
created_at: 2025-03-05T18:19:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,7 +73,9 @@
 #define _GL_CMP(n1, n2) (((n1) > (n2)) - ((n1) < (n2)))
 
 /* Difference or zero */
-#define DOZ(a, b) ((a) > (b) ? (a) - (b) : 0)
+#define DOZ(a, b)        ((a) > (b) ? (a) - (b) : 0)
+
+#define ARRAY_LEN(array) (sizeof (array) / sizeof ((array)[0]))
path: lib/util.h
user: login: mc-worker
body: GLib provides G_N_ELEMENTS() macro,
created_at: 2025-03-05T18:20:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
path: src/subshell/common.c
user: login: mc-worker
body: At least this enum should go to the top of function.
created_at: 2025-03-05T18:21:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
+    {
+        shift = 0x01,
+        ctrl = 0x04,
+        caps_lock = 0x40,
+        num_lock = 0x80,
+
+        evt_press = 1,
+        evt_repeat = 2
+    };
+
+    // Apply any shift modifier.
+    // Note that if both shift and caps set, there should be no shifted key.
+    if ((modifiers & (shift | caps_lock)) && shifted_key)
path: src/subshell/common.c
user: login: mc-worker
body: Please use explicit comparison for integers: `(modifiers & (shift | caps_lock)) != 0 && shifted_key != 0`
created_at: 2025-03-05T18:23:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
+    {
+        shift = 0x01,
+        ctrl = 0x04,
+        caps_lock = 0x40,
+        num_lock = 0x80,
+
+        evt_press = 1,
+        evt_repeat = 2
+    };
+
+    // Apply any shift modifier.
+    // Note that if both shift and caps set, there should be no shifted key.
+    if ((modifiers & (shift | caps_lock)) && shifted_key)
+    {
+        codepoint = shifted_key;
+        modifiers &= ~shift;
+    }
+
+    if (codepoint != 'o' || (modifiers & ~(caps_lock | num_lock)) != ctrl)
+        return FALSE;
+    if (event && event != evt_press && event != evt_repeat)
path: src/subshell/common.c
user: login: mc-worker
body: Please use explicit comparison for integers: `event != 0`.

created_at: 2025-03-05T18:23:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
+    {
+        shift = 0x01,
+        ctrl = 0x04,
+        caps_lock = 0x40,
+        num_lock = 0x80,
+
+        evt_press = 1,
+        evt_repeat = 2
+    };
+
+    // Apply any shift modifier.
+    // Note that if both shift and caps set, there should be no shifted key.
+    if ((modifiers & (shift | caps_lock)) && shifted_key)
+    {
+        codepoint = shifted_key;
+        modifiers &= ~shift;
+    }
+
+    if (codepoint != 'o' || (modifiers & ~(caps_lock | num_lock)) != ctrl)
+        return FALSE;
+    if (event && event != evt_press && event != evt_repeat)
+        return FALSE;
+    return TRUE;
path: src/subshell/common.c
user: login: mc-worker
body: `return (event == 0 || event == evt_press || event == evt_repeat);`
created_at: 2025-03-05T18:25:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,63 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
+    buffer += 2;
+    len -= 2;
+    if (!parse_csi (&csi, &buffer, buffer + len))
+        return FALSE;
+
+    const char csi_final_byte = buffer[-1];
+    if (csi.private_mode || csi_final_byte != 'u')
+        return FALSE;
+    if (csi.param_count != 2)
+        return FALSE;
+    uint32_t codepoint = csi.params[0][0];
+    const uint32_t shifted_key = csi.params[0][1];
+    if (csi.params[1][0] == 0)  // Bad modifier.
+        return FALSE;
+    uint32_t modifiers = csi.params[1][0] - 1;
+    const uint32_t event = csi.params[1][1];
+
+    enum
+    {
+        shift = 0x01,
+        ctrl = 0x04,
+        caps_lock = 0x40,
+        num_lock = 0x80,
+
+        evt_press = 1,
+        evt_repeat = 2
+    };
+
+    // Apply any shift modifier.
+    // Note that if both shift and caps set, there should be no shifted key.
+    if ((modifiers & (shift | caps_lock)) && shifted_key)
+    {
+        codepoint = shifted_key;
+        modifiers &= ~shift;
+    }
+
+    if (codepoint != 'o' || (modifiers & ~(caps_lock | num_lock)) != ctrl)
+        return FALSE;
+    if (event && event != evt_press && event != evt_repeat)
+        return FALSE;
+    return TRUE;
path: src/subshell/common.c
user: login: krobelus
body: not sure I agree but I guess
created_at: 2025-03-05T21:15:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/terminal.h"
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+/* --------------------------------------------------------------------------------------------- */
+
+/**
+ * Parse a CSI command, starting from the third byte (i.e. the first
+ * parameter byte, if any).
+ *
+ * On success, *sptr will point to one-past the end of the sequence.
+ * On failure, *sptr will point to the first invalid byte.
+ *
+ * Here's the format in a sort of pidgin BNF:
+ *
+ * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
+ * parameters = [0-9;:]+
+ *            | [<=>?] (parameter-byte)* # private mode
+ * parameter-byte = [\x30-\x3F]     # one of "0-9;:<=>?"
+ * intermediate-byte = [\x20–\x2F]  # one of " !\"#$%&'()*+,-./"
+ * final-byte = [\x40-\x7e]         # one of "@A–Z[\]^_`a–z{|}~"
+ */
+gboolean
+parse_csi (struct csi_command_t *out, const char **sptr, const char *end)
+{
+    gboolean ok = FALSE;
+    const char *s = *sptr;
+    if (s == end)
+        goto invalid_sequence;
+    char c = *s;
+
+#define NEXT_CHAR                                                                                  \
+    do                                                                                             \
+    {                                                                                              \
+        if (++s == end)                                                                            \
+            goto invalid_sequence;                                                                 \
+        c = *s;                                                                                    \
+    }                                                                                              \
+    while (0)
+
+    char private_mode = '\0';
+    assert (c != '\0');
+    if (strchr ("<=>?", c) != NULL)
+    {
+        private_mode = c;
+        NEXT_CHAR;
+    }
+
+    // parameter bytes
+    size_t param_count = 0;
+    if (private_mode)
+        while (c >= 0x30 && c <= 0x3F)
+            NEXT_CHAR;
+    else
+    {
+        if (out)
+            // N.B. Empty parameter strings are allowed. For our current use,
+            // defaulting to zero happens to work.
+            memset (out->params, 0, sizeof (out->params));
+        uint32_t tmp = 0;
+        size_t sub_index = 0;
+        while (c >= 0x30 && c <= 0x3F)
+        {
+            if (c >= '0' && c <= '9')
+            {
+                if (param_count == 0)
+                    param_count = 1;
+                if (tmp * 10 < tmp)
path: lib/terminal.c
user: login: krobelus
body: overflow check. I'm not sure what would be a better way
created_at: 2025-03-05T21:16:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -465,7 +465,7 @@ init_subshell_child (const char *pty_name)
 
     case SHELL_FISH:
         execl (mc_global.shell->path, mc_global.shell->path, "--init-command",
-               "set --global __mc_csi_u 1", (char *) NULL);
+               "set --global __mc_kitty_keyboard 1", (char *) NULL);
path: src/subshell/common.c
user: login: zyv
body: Since you care about commit messages, in 5dc7aefcae8514cfda1791b0f2abb7c23b3621f4:

> I guess we could avoid that but I don't think this
> is **not** a big loss, especially because since fish 4.0.1 will enable it again.

The double negation is wrong.
created_at: 2025-03-06T08:21:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,292 @@
+/*
+   Terminal emulation.
+
+   Copyright (C) 2025
+   Free Software Foundation, Inc.
+
+   This file is part of the Midnight Commander.
+   Authors:
+   Miguel de Icaza, 1994, 1995, 1996
+   Johannes Altmanninger, 2025
+
+   The Midnight Commander is free software: you can redistribute it
+   and/or modify it under the terms of the GNU General Public License as
+   published by the Free Software Foundation, either version 3 of the License,
+   or (at your option) any later version.
+
+   The Midnight Commander is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/** \file terminal.c
+ *  \brief Source: terminal emulation.
+ *  \author Johannes Altmanninger
+ *  \date 2025
+ *
+ *  Subshells running inside Midnight Commander may assume they run inside
+ *  a terminal. This module helps us act like a real terminal in relevant
+ *  aspects.
+ */
+
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+#include "lib/terminal.h"
+
path: lib/terminal.c
user: login: mc-worker
body: Please apply template fully here.
created_at: 2025-03-06T18:12:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -782,6 +777,57 @@ set_prompt_string (void)
     setup_cmdline ();
 }
 
+/* --------------------------------------------------------------------------------------------- */
+
+enum kitty_keyboard_protocol_t
+{
+    MODIFIER_SHIFT = 0x01,
+    MODIFIER_CTRL = 0x04,
+    MODIFIER_CAPS_LOCK = 0x40,
+    MODIFIER_NUM_LOCK = 0x80,
+
+    EVENT_TYPE_PRESS = 1,
+    EVENT_TYPE_REPEAT = 2
+};
+
+static gboolean
+peek_subshell_switch_key (const char *buffer, size_t len)
+{
+    if (len == 0)
+        return FALSE;
+    if (buffer[0] == (XCTRL ('o') & 255))
+        return TRUE;
+
+    // Also check if ctrl-o is encoded via the kitty keyboard protocol.
+    if (len == 1)
+        return FALSE;
+    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
+        return FALSE;
+    struct csi_command_t csi;
path: src/subshell/common.c
user: login: mc-worker
body: Please don't overuse of code and variables mix. Such code is hard-readable: the variable declaration is badly visible between the lines of code. It's better to add an empty lines before and after variable declaration(s).
created_at: 2025-03-06T18:17:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,292 @@
+/*
+   Terminal emulation.
+
+   Copyright (C) 2025
+   Free Software Foundation, Inc.
+
+   This file is part of the Midnight Commander.
+   Authors:
+   Miguel de Icaza, 1994, 1995, 1996
+   Johannes Altmanninger, 2025
+
+   The Midnight Commander is free software: you can redistribute it
+   and/or modify it under the terms of the GNU General Public License as
+   published by the Free Software Foundation, either version 3 of the License,
+   or (at your option) any later version.
+
+   The Midnight Commander is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/** \file terminal.c
+ *  \brief Source: terminal emulation.
+ *  \author Johannes Altmanninger
+ *  \date 2025
+ *
+ *  Subshells running inside Midnight Commander may assume they run inside
+ *  a terminal. This module helps us act like a real terminal in relevant
+ *  aspects.
+ */
+
+#include <config.h>
+
+#include <assert.h>
+
+#include "lib/util.h"
+#include "lib/strutil.h"
+
+#include "lib/terminal.h"
+
path: lib/terminal.c
user: login: zyv
body: For reference:

https://github.com/MidnightCommander/mc/blob/master/maint/templates/template.c
created_at: 2025-03-07T09:34:11Z
author_association: MEMBER

PR comments:
user: login: ossilator
created_at: 2025-03-05T15:37:06Z
body: [`#1` reason why GH reviews suck: one can't inline-comment on commit messages (which is such an obvious and easily fixable omission that one can't avoid thinking that this is intentional). did i already mention gerrithub ...? :grin: ]

the 3rd commit message (and the PR description) say "add teach".

from a cursory look, the kitty kbd proto implements everything i "asked for" in #2150, so: yay.

capslock/shiftlock should definitely not suppress control sequences.
as for shift ... i guess it's fine if this does, because:
- "nobody" should be doing that (intentionally), as it's pointless
- terminals may hijack these combos for their own purposes anyway (konsole does)
author_association: CONTRIBUTOR
user: login: krobelus
created_at: 2025-03-05T21:15:25Z
body: > Hey, thanks for a quality contribution featuring... a test!!!11 I'm
> not sure if you're the first ever

there's a lot of boilerplate to adding tests I guess.
(I think the foot terminal defines a neat `UNITTEST` macro.)

> 
> I'm really unfamiliar with the code and have little idea about the
> subject,

it's mainly about implementing section 5.4 (Control sequences) from
ECMA-48 and the kitty keyboard protocol, for the sole purpose of
letting us decode ctrl-o reliably.

> hopefully @aborodin can say something. I only left formal
> comments that can be easily addressed. Also, please fix the build
> on Solaris.
> 
> I guess you missed the END_TEST macro.
> 
> @asl could you please report whether this helps?

I think this problem was only seen by @Crashdummyy.

Note I've added another commit that will cause fish to not enable kitty
protocol unless known safe.  So you'd need to test **without** the last commit.

> > Note that capslock still disables ctrl-o. We could change that easily
> > (and for consistency also allow ctrl-O).
> 
> I'm not sure if we generally recognize shifted stuff, and if we
> do, how it works. I'm afraid to open this can of worms. There is
> some information in #2150 ... @ossilator and @egmontkob know more
> about this.

This patch does add some parsing code that could be used in future to
fix key bindings when inside mc (which would need mc to explictly
request the kitty keyboard protocol).
But for now it's only fixing a preexisting issue.

> 
> P.S. How amazing was it to enjoy the privilege of using GitHub for
> issues and a pull-request submission interface?
> 
> I know I probably can't impress people with this, but you're the
> one who experienced our venerable Trac... oh man, that was a hell
> of a lot of work. I need some encouragement 😅

I've been watching that from afar, seems pretty nice. GitHub API docs
are quite good IIRC.
Having to upload patches in the browser was definitely suboptimal.

> [#1 reason why GH reviews suck: one can't inline-comment on commit
> messages (which is such an obvious and easily fixable omission that
> one can't avoid thinking that this is intentional).

Yeah, I think github wants to keep people on their website, so they
boycott commit messages (the github cofounder said "it's too difficult
to find commit messages" -- well, guess who's too blame).  Github also
encourages people to create messy commit histories.  I prefer the
mailing list style. But whatever gets people to contribute.

> did i already mention gerrithub ...? 😁 ]
> 
> the 3rd commit message (and the PR description) say "add teach".
> 
> from a cursory look, the kitty kbd proto implements everything i "asked for" in #2150, so: yay.
> 
> capslock/shiftlock should definitely not suppress control sequences.

ok I made it so c-o still works when capslock is on.
c-s-o works too because from a user perspective that's exactly the same thing as c-o with capslock on.
Would need a good reason to treat them differently.

> as for shift ... i guess it's fine if this does, because:
> 
> "nobody" should be doing that (intentionally), as it's pointless
> terminals may hijack these combos for their own purposes anyway (konsole does)

Konsole also hijacks alt-f, that would be very annoying to use for me.

---

Extracted the test to separate commit.

<details>

<summary>
range-diff
</summary>

```
1:  182ba30ab ! 1:  20e6d84f9 Extract escape-sequence parsing logic into new module
    @@ Metadata
      ## Commit message ##
         Extract escape-sequence parsing logic into new module
     
    -    A following commit wants to add more logic and a unit test related to decoding
    -    terminal escape sequences and the kitty keyboard protocol.  It's probably
    -    not right to keep all of this in lib/util.c, so extract a new module.
    +    Following commits want to add more logic (and tests) for decoding terminal
    +    escape sequences, and implement a subset of the kitty keyboard protocol.
    +    It's probably not right to keep all of this in lib/util.c, so extract a
    +    new module.
     
    -    I called it "terminal" as first approximation - the name could be workshopped,
    -    especially since we have a lot of terminal-specific code all around. We also
    -    have lib/tty/tty.h though that's slightly different.
    +    I called it "terminal" because it helps mc act as terminal.
    +
    +    We also have lib/tty/tty.h though that's for talking to the actual terminal
    +    that mc is running inside.
     
         Signed-off-by: Johannes Altmanninger <aclopte@gmail.com>
     
      ## lib/Makefile.am ##
     @@ lib/Makefile.am: SUBLIB_includes = \
    + 	widget.h
    + 
      SRC_mc_utils = \
    ++	terminal.c terminal.h \
      	utilunix.c \
      	unixcompat.h \
    -+	terminal.c terminal.h \
      	util.c util.h
    - 
    - 
     
      ## lib/terminal.c (new) ##
     @@
    ++/*
    ++   Terminal emulation.
    ++
    ++   Copyright (C) 2025
    ++   Free Software Foundation, Inc.
    ++
    ++   This file is part of the Midnight Commander.
    ++   Authors:
    ++   Miguel de Icaza, 1994, 1995, 1996
    ++   Johannes Altmanninger, 2025
    ++
    ++   The Midnight Commander is free software: you can redistribute it
    ++   and/or modify it under the terms of the GNU General Public License as
    ++   published by the Free Software Foundation, either version 3 of the License,
    ++   or (at your option) any later version.
    ++
    ++   The Midnight Commander is distributed in the hope that it will be useful,
    ++   but WITHOUT ANY WARRANTY; without even the implied warranty of
    ++   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ++   GNU General Public License for more details.
    ++
    ++   You should have received a copy of the GNU General Public License
    ++   along with this program.  If not, see <https://www.gnu.org/licenses/>.
    ++ */
    ++
    ++/** \file terminal.c
    ++ *  \brief Source: terminal emulation.
    ++ *  \author Johannes Altmanninger
    ++ *  \date 2025
    ++ *
    ++ *  Subshells running inside Midnight Commander may assume they run inside
    ++ *  a terminal. This module helps us act like a real terminal in relevant
    ++ *  aspects.
    ++ */
    ++
     +#include <config.h>
     +
    -+#include "lib/terminal.h"
    ++#include <assert.h>
    ++
     +#include "lib/util.h"
     +#include "lib/strutil.h"
     +
    ++#include "lib/terminal.h"
    ++
     +/* --------------------------------------------------------------------------------------------- */
     +/**
     + * Remove all control sequences from the argument string.  We define
    @@ lib/terminal.c (new)
     +                while (*r != '\0' && (*r < 0x40 || *r > 0x7E))
     +                    ++r;
     +            }
    -+            else if (*r == ']')
    ++            if (*r == ']')
     +            {
     +                /*
     +                 * Skip xterm's OSC (Operating System Command)
    @@ lib/terminal.c (new)
     +        {
     +            char *n;
     +
    -+            n = str_get_next_char (r);
    ++            n = str_cget_next_char (r);
     +            if (str_isprint (r))
     +            {
     +                memmove (w, r, n - r);
    @@ lib/terminal.c (new)
     +
     +/* --------------------------------------------------------------------------------------------- */
     
    - ## lib/terminal.h (new) ##
    -@@
    -+/** \file terminal.h
    -+ *  \brief Header: terminal command decoding. See also tty.h
    -+ */
    -+
    -+#ifndef MC_TERMINAL_H
    -+#define MC_TERMINAL_H
    -+
    -+#include <sys/types.h>
    -+#include <inttypes.h>  // uintmax_t
    -+
    -+#include "lib/global.h"  // include <glib.h>
    -+
    -+char *strip_ctrl_codes (char *s);
    -+
    -+/* Replaces "\\E" and "\\e" with "\033". Replaces "^" + [a-z] with
    -+ * ((char) 1 + (c - 'a')). The same goes for "^" + [A-Z].
    -+ * Returns a newly allocated string. */
    -+char *convert_controls (const char *s);
    -+
    -+#endif
    -
      ## lib/util.c ##
     @@ lib/util.c: skip_numbers (const char *s)
          return su;
    @@ src/subshell/common.c
      #include "lib/unixcompat.h"
      #include "lib/tty/tty.h"  // LINES
      #include "lib/tty/key.h"  // XCTRL
    -
    - ## tests/src/Makefile.am ##
    -@@
    - PACKAGE_STRING = "/src"
    - 
    --SUBDIRS = . filemanager vfs
    -+SUBDIRS = . filemanager lib vfs
    - 
    - if USE_INTERNAL_EDIT
    - SUBDIRS += editor
-:  --------- > 2:  19b0cc97f Test for stripping control codes
2:  d1f2607f5 ! 3:  64140668c const-correct strip_ctrl_codes
    @@ lib/terminal.c: strip_ctrl_codes (char *s)
     -            char *n;
     +            const char *n;
      
    --            n = str_get_next_char (r);
    -+            n = str_cget_next_char (r);
    +             n = str_cget_next_char (r);
                  if (str_isprint (r))
    -             {
    -                 memmove (w, r, n - r);
3:  00eb67a8e ! 4:  cf671cfe2 Fix kitty keyboard protocol parsing of ctrl-o
    @@ Commit message
         the kitty keyboard protocol specifies various other sequences that also
         mean ctrl-o. Specifically, when numlock is active.
     
    -    Instead of matching raw byte values, let's add teach our ECMA-48 CSI parser to
    -    decode parameter bytes and interpret them according to the kitty keyboard protocol.
    +    Instead of matching raw byte values, let's teach our ECMA-48 CSI parser to
    +    decode parameter bytes and interpret them according to the kitty keyboard
    +    protocol.
     
    -    Tested with fish version 4:
    +    Tested with fish version 4 on kitty:
     
                 1. Turn on numlock
                 2. SHELL=/bin/fish src/mc
    @@ Commit message
                 4. Enter
                 5. ctrl-o -- confirmed that this one is recognized now
     
    -    Note that capslock still disables ctrl-o. We could change that easily
    -    (and for consistency also allow ctrl-O).
    -
    -    While at it, add some unit tests (using a fish prompt as input data).
    +    Make ctrl-O do the same thing I guess, to make switching easier if capslock
    +    is in effect.
     
         Signed-off-by: Johannes Altmanninger <aclopte@gmail.com>
     
    - ## configure.ac ##
    -@@ configure.ac: tests/lib/widget/Makefile
    - tests/src/Makefile
    - tests/src/filemanager/Makefile
    - tests/src/editor/Makefile
    -+tests/src/lib/Makefile
    - tests/src/editor/test-data.txt
    - tests/src/vfs/Makefile
    - tests/src/vfs/extfs/Makefile
    -
      ## lib/terminal.c ##
     @@
    - #include <config.h>
    - 
    -+#include <assert.h>
    -+
      #include "lib/terminal.h"
    - #include "lib/util.h"
    - #include "lib/strutil.h"
      
      /* --------------------------------------------------------------------------------------------- */
     +
    @@ lib/terminal.c
     + *
     + * On success, *sptr will point to one-past the end of the sequence.
     + * On failure, *sptr will point to the first invalid byte.
    -+ *
    -+ * Here's the format in a sort of pidgin BNF:
       *
     - * control-seq = Esc non-'['
     - *             | Esc '[' (parameter-byte)* (intermediate-byte)* final-byte
    ++ * Here's the format in a sort of pidgin BNF:
    ++ *
     + * CSI-command = Esc '[' parameters (intermediate-byte)* final-byte
     + * parameters = [0-9;:]+
     + *            | [<=>?] (parameter-byte)* # private mode
    @@ lib/terminal.c
     +    while (0)
     +
     +    char private_mode = '\0';
    -+    assert (c != '\0');
    ++    g_assert (c != '\0');
     +    if (strchr ("<=>?", c) != NULL)
     +    {
     +        private_mode = c;
    @@ lib/terminal.c
     +
     +    // parameter bytes
     +    size_t param_count = 0;
    -+    if (private_mode)
    ++    if (private_mode != '\0')
     +        while (c >= 0x30 && c <= 0x3F)
     +            NEXT_CHAR;
     +    else
     +    {
    -+        if (out)
    ++        if (out != NULL)
     +            // N.B. Empty parameter strings are allowed. For our current use,
     +            // defaulting to zero happens to work.
     +            memset (out->params, 0, sizeof (out->params));
    @@ lib/terminal.c
     +                if (param_count == 0)
     +                    param_count = 1;
     +                if (tmp * 10 < tmp)
    -+                    goto invalid_sequence;
    ++                    goto invalid_sequence;  // overflow
     +                tmp *= 10;
    ++                if (tmp + c - '0' < tmp)
    ++                    goto invalid_sequence;  // overflow
     +                tmp += c - '0';
    -+                if (out)
    ++                if (out != NULL)
     +                    out->params[param_count - 1][sub_index] = tmp;
     +            }
    -+            else if (c == ':' && ++sub_index < ARRAY_LEN (out->params[0]))
    ++            else if (c == ':' && ++sub_index < G_N_ELEMENTS (out->params[0]))
     +                tmp = 0;
    -+            else if (c == ';' && ++param_count <= ARRAY_LEN (out->params))
    ++            else if (c == ';' && ++param_count <= G_N_ELEMENTS (out->params))
     +                tmp = sub_index = 0;
     +            else
     +                goto invalid_sequence;
    @@ lib/terminal.c
     +        goto invalid_sequence;
     +    ++s;
     +    ok = TRUE;
    -+    if (out)
    ++    if (out != NULL)
     +    {
     +        out->private_mode = private_mode;
     +        out->param_count = param_count;
    @@ lib/terminal.c
     +    return ok;
     +}
     +
    -+/* ---------------------------------------------------------------------------------------------
    -+ */
    ++/* --------------------------------------------------------------------------------------------- */
     +/**
     + * Remove all control sequences (CSI, OSC) from the argument string.
       *
    @@ lib/terminal.c: strip_ctrl_codes (char *s)
     +                // We're already past the sequence, no need to increment.
     +                continue;
                  }
    -             else if (*r == ']')
    +             if (*r == ']')
                  {
     @@ lib/terminal.c: strip_ctrl_codes (char *s)
      }
    @@ lib/terminal.c: convert_controls (const char *p)
     -
     -/* --------------------------------------------------------------------------------------------- */
     
    - ## lib/terminal.h ##
    + ## lib/terminal.h (new) ##
     @@
    - 
    - #include "lib/global.h"  // include <glib.h>
    - 
    ++/** \file terminal.h
    ++ *  \brief Header: terminal emulation logic.
    ++ */
    ++
    ++#ifndef MC_TERMINAL_H
    ++#define MC_TERMINAL_H
    ++
    ++#include <sys/types.h>
    ++#include <inttypes.h>  // uint32_t
    ++
    ++#include "lib/global.h"  // include <glib.h>
    ++
     +struct csi_command_t
     +{
     +    char private_mode;
    @@ lib/terminal.h
     +};
     +gboolean parse_csi (struct csi_command_t *out, const char **sptr, const char *end);
     +
    - char *strip_ctrl_codes (char *s);
    - 
    - /* Replaces "\\E" and "\\e" with "\033". Replaces "^" + [a-z] with
    -
    - ## lib/util.h ##
    -@@
    - #define _GL_CMP(n1, n2) (((n1) > (n2)) - ((n1) < (n2)))
    - 
    - /* Difference or zero */
    --#define DOZ(a, b) ((a) > (b) ? (a) - (b) : 0)
    -+#define DOZ(a, b)        ((a) > (b) ? (a) - (b) : 0)
    ++char *strip_ctrl_codes (char *s);
     +
    -+#define ARRAY_LEN(array) (sizeof (array) / sizeof ((array)[0]))
    - 
    - /* flags for shell_execute */
    - #define EXECUTE_INTERNAL (1 << 0)
    ++/* Replaces "\\E" and "\\e" with "\033". Replaces "^" + [a-z] with
    ++ * ((char) 1 + (c - 'a')). The same goes for "^" + [A-Z].
    ++ * Returns a newly allocated string. */
    ++char *convert_controls (const char *s);
    ++
    ++#endif
     
      ## src/subshell/common.c ##
     @@
    @@ src/subshell/common.c: set_prompt_string (void)
          setup_cmdline ();
      }
      
    ++enum kitty_keyboard_protocol
    ++{
    ++    modifier_shift = 0x01,
    ++    modifier_ctrl = 0x04,
    ++    modifier_caps_lock = 0x40,
    ++    modifier_num_lock = 0x80,
    ++
    ++    modifier_shifted = modifier_shift | modifier_caps_lock,
    ++
    ++    event_type_press = 1,
    ++    event_type_repeat = 2
    ++};
    ++
     +static gboolean
     +peek_subshell_switch_key (const char *buffer, size_t len)
     +{
    @@ src/subshell/common.c: set_prompt_string (void)
     +    if (buffer[0] == (XCTRL ('o') & 255))
     +        return TRUE;
     +
    -+    // Also check if ctrl-o is encoded as specified by the kitty keyboard protocol.
    ++    // Also check if ctrl-o is encoded via the kitty keyboard protocol.
     +    if (len == 1)
     +        return FALSE;
     +    if (buffer[0] != ESC_CHAR || buffer[1] != '[')  // CSI
    @@ src/subshell/common.c: set_prompt_string (void)
     +    uint32_t modifiers = csi.params[1][0] - 1;
     +    const uint32_t event = csi.params[1][1];
     +
    -+    enum
    -+    {
    -+        shift = 0x01,
    -+        ctrl = 0x04,
    -+        caps_lock = 0x40,
    -+        num_lock = 0x80,
    -+
    -+        evt_press = 1,
    -+        evt_repeat = 2
    -+    };
    -+
     +    // Apply any shift modifier.
    -+    // Note that if both shift and caps set, there should be no shifted key.
    -+    if ((modifiers & (shift | caps_lock)) && shifted_key)
    -+    {
    ++    // Note that if both shift and caps lock are set, there should be no shifted key.
    ++    if ((modifiers & modifier_shifted) != 0 && shifted_key)
     +        codepoint = shifted_key;
    -+        modifiers &= ~shift;
    -+    }
    ++    modifiers &= ~modifier_shifted;
     +
    -+    if (codepoint != 'o' || (modifiers & ~(caps_lock | num_lock)) != ctrl)
    -+        return FALSE;
    -+    if (event && event != evt_press && event != evt_repeat)
    ++    if ((codepoint != 'o' && codepoint != 'O') || (modifiers & ~modifier_num_lock) != modifier_ctrl)
     +        return FALSE;
    -+    return TRUE;
    ++    return event == 0 || event == event_type_press || event == event_type_repeat;
     +}
     +
      /* --------------------------------------------------------------------------------------------- */
    @@ src/subshell/common.c: feed_subshell (int how, gboolean fail_on_error)
                          write_all (mc_global.tty.subshell_pty, pty_buffer, i);
      
     
    - ## tests/src/lib/.gitignore (new) ##
    -@@
    -+terminal
    -
    - ## tests/src/lib/Makefile.am (new) ##
    -@@
    -+PACKAGE_STRING = "/src/lib"
    -+
    -+AM_CPPFLAGS = \
    -+	-DTEST_SHARE_DIR=\"$(abs_srcdir)\" \
    -+	$(GLIB_CFLAGS) \
    -+	-I$(top_srcdir) \
    -+	@CHECK_CFLAGS@
    -+
    -+LIBS = @CHECK_LIBS@ \
    -+	$(top_builddir)/src/libinternal.la \
    -+	$(top_builddir)/lib/libmc.la
    -+
    -+if ENABLE_MCLIB
    -+LIBS += $(GLIB_LIBS)
    -+endif
    -+
    -+TESTS = \
    -+	terminal
    -+
    -+check_PROGRAMS = $(TESTS)
    -+
    -+edit_complete_word_cmd_SOURCES = \
    -+	terminal.c
    -
    - ## tests/src/lib/terminal.c (new) ##
    -@@
    -+#include <string.h>
    -+#include <assert.h>
    -+
    -+#include <config.h>
    -+#include "lib/global.h"  // include <glib.h>
    -+#include "lib/terminal.h"
    -+#include "lib/strutil.h"
    -+
    -+#define TEST_SUITE_NAME "/src/lib/terminal"
    -+
    -+#include "tests/mctest.h"
    -+
    -+/* --------------------------------------------------------------------------------------------- */
    -+
    -+static void
    -+setup (void)
    -+{
    -+    str_init_strings (NULL);
    -+}
    -+
    -+static void
    -+teardown (void)
    -+{
    -+    str_uninit_strings ();
    -+}
    -+
    -+/* --------------------------------------------------------------------------------------------- */
    -+
    + ## tests/lib/terminal.c ##
    +@@ tests/lib/terminal.c: teardown (void)
    + 
    + /* --------------------------------------------------------------------------------------------- */
    + 
     +START_TEST (test_parse_csi)
     +{
     +    const char *s = &"\x1b[=5uRest"[2];
    @@ tests/src/lib/terminal.c (new)
     +
     +/* --------------------------------------------------------------------------------------------- */
     +
    -+START_TEST (test_strip_ctrl_codes)
    -+{
    -+    char *s = strdup (
    -+        "\033]0;~\a\033[30m\033(B\033[m\033]133;A;special_key=1\a$ "
    -+        "\033[K\033[?2004h\033[>4;1m\033[=5u\033=\033[?2004l\033[>4;0m\033[=0u\033>\033[?2004h\033["
    -+        ">4;1m\033[=5u\033=\033[?2004l\033[>4;0m\033[=0u\033>\033[?2004h\033[>4;1m\033[=5u\033=");
    -+    char *actual = strip_ctrl_codes (s);
    -+    const char *expected = "$ ";
    -+    ck_assert_str_eq (actual, expected);
    -+    free (s);
    -+}
    -+
    -+/* --------------------------------------------------------------------------------------------- */
    -+
    -+int
    -+main (void)
    -+{
    -+    TCase *tc_core;
    -+
    -+    tc_core = tcase_create ("Core");
    -+
    -+    tcase_add_checked_fixture (tc_core, setup, teardown);
    -+
    -+    // Add new tests here: ***************
    + START_TEST (test_strip_ctrl_codes)
    + {
    +     char *s = strdup (
    +@@ tests/lib/terminal.c: main (void)
    +     tcase_add_checked_fixture (tc_core, setup, teardown);
    + 
    +     // Add new tests here: ***************
     +    tcase_add_test (tc_core, test_parse_csi);
    -+    tcase_add_test (tc_core, test_strip_ctrl_codes);
    -+    // ***********************************
    -+
    -+    return mctest_run_all (tc_core);
    -+}
    -+
    -+/* --------------------------------------------------------------------------------------------- */
    +     tcase_add_test (tc_core, test_strip_ctrl_codes);
    +     // ***********************************
    + 
-:  --------- > 5:  79c6a1968 subshell: tell fish that we actually support the kitty keyboard protocol now
```

</details>

author_association: CONTRIBUTOR
user: login: egmontkob
created_at: 2025-03-06T07:19:40Z
body: > egmontkob know more about this

No, unfortunately I don't. I'm not familiar with Kitty's keyboard protocol at all. So I'll sit out this discussion.


author_association: CONTRIBUTOR
user: login: zyv
created_at: 2025-03-06T07:56:54Z
body: > there's a lot of boilerplate to adding tests I guess.
> (I think the foot terminal defines a neat UNITTEST macro.)

Well, you didn't just add a test, you added a whole new test suite. I don't see much difference between our current `START_TEST` / `END_TEST` and Foot's `UNITTEST` macro. 

Foot seems to put their tests directly into the source files instead of having a separate tree of suites and tests. I can see how this makes it easier to add tests. It's not that it's an uncontroversial decision, though.

I think the biggest problem is that we have so few tests that it's basically not about finding a suite and adding your little new test to it, it's about writing a whole new suite from scratch. But that is a chicken and egg problem. If we don't add more suites and tests, there will never be enough of them to make it easy to add a new test to an existing suite.

However, my impression so far has been that most contributors either object on principle, calling it useless bureaucracy, or expect maintainers to write tests for them. That's why I'm so glad you don't seem to exhibit either of those behaviors.

> I've been watching that from afar, seems pretty nice. GitHub API docs
> are quite good IIRC.

Living in Germany, I've seen a lot worse. They do have their own quirks that you have to learn by doing though...

It seems that the REST API has taken a back seat and all new stuff goes into GraphQL, but this is somehow not really made explicit anywhere, so you basically start doing something with REST and bump into missing things, which eventually leads to throwing the whole thing out and starting from scratch with GraphQL.

On top of that, their import API has never left "unofficial" status and remains REST. While it's (thankfully) not completely broken yet, it means you don't get a decent client for it ([had to code my own](https://github.com/zyv/github-issues-import)) and it also doesn't support new features like resolutions, issue types, etc.

But yeah, I am very happy with the results so far :)

> Yeah, I think github wants to keep people on their website, so they
> boycott commit messages (the github cofounder said "it's too difficult
> to find commit messages" -- well, guess who's too blame).

I think it's more of a (self-reinforcing) cultural lack of demand these days than a conspiracy against commit messages... They are doing things that enterprise customers are screaming about. Most of the recent improvements to Issues and Projects are well done, in my opinion.

> Github also encourages people to create messy commit histories.

But (unlike for commit messages) they have the tools for those who care to make it work. As you can see (well, probably not, since you don't have the commit bit), I have made it so that the PRs cannot be merged unless they are up-to-date on `master` and properly rebased (for which I have implemented a `/rebase` command).

So I hope we can keep our working style of committing `fixups` (instead of mostly force-pushing series of patches) and then rebasing and merging with one command. One big advantage I see with `fixup` is that it's naturally easy to see which comments have been fixed and which have not. With a force-push (yes, sometimes it's unavoidable) I would have to go through the whole patch again.

author_association: MEMBER
user: login: ossilator
created_at: 2025-03-06T10:59:09Z
body: > > c-s-o works too
> > because from a user perspective that's exactly the same thing as c-o with capslock on.

absolutely not.
as a (not really) proof, consider that capslock doesn't lock the digit row, so it's not at all like actually pressing shift (unlike shiftlock on for example the german layout).
but more to the point, the keyboard being in a particular logical state is quite different from physically holding down a modifier, and specifically for the purpose of emitting control sequences, it would be highly unexpected for capslock to have an effect.
 
> One big advantage I see with `fixup` is that it's naturally easy to see which comments have been fixed and which have not. With a force-push (yes, sometimes it's unavoidable) I would have to go through the whole patch again.

well, to me this sounds just backwards, like typical workaround thinking for bad tooling. squashing the fixups is a potentially error-prone process (the necessary reordering may cause conflicts), so it shouldn't be delayed. and of course gerrit has an answer to that: diffs between patchsets (versions of a change, or in practice, actual commits), also called inter-diffs, are a rather central element of the workflow.

seriously, just import mc to gerrithub (i would do that myself, but you'd need to give me full workflow permissions for the GH project) and import the pending PRs (after squashing the fixups), and see how it looks in practice. i've yet to see a developer with high standards who has looked back.
(it occurs to me that this would have been a good argument for converting the tickets with associated branches to PRs.)


author_association: CONTRIBUTOR
user: login: krobelus
created_at: 2025-03-06T11:04:32Z
body: > That is, Ctrl-O and Ctrl-Shift-O are definitely different, and both
> work regardless of whether CapsLock is on or not.

Ok that makes sense, done.

> > range-diff
> How did you do that? This could be useful to learn.

It's a "diff of patches" (or "diff of diffs"), useful after force
push. For example

	git range-diff bc213f6ff...1cf6b5814
	git range-diff origin/some-branch@{1}...origin/some-branch # show the diff since the last fetch

I think it takes some time to get accustomed to it,
but it's often the easiest way to find what changed.
author_association: CONTRIBUTOR
user: login: krobelus
created_at: 2025-03-06T11:39:49Z
body: > I have made it so that the PRs cannot be merged unless they are
> up-to-date on master and properly rebased (for which I have implemented
> a /rebase command).

Note that CI tests should typically run on a merge commit implicitly created
by the CI (not sure what's the default in github), meaning that an up-to-date
version will be tested, even if the PR branch is old.

After that suceeds, a `gh pr merge --rebase <PR-URL>` should result in the
same Git tree unless something else merged in the meantime.

> > So I hope we can keep our working style of committing fixups (instead
> > of mostly force-pushing series of patches) and then rebasing and
> > merging with one command.

I have no horse in this race but I'm strongly in favor of cleaning
up history early and often.

I don't think it's right to optimize for the hypothetical reviewer
that has good enough memory of a previous version of a patch series to
be able to benefit from multiple out-of-context commits added on top.

> > One big advantage I see with fixup is
> > that it's naturally easy to see which comments have been fixed and
> > which have not. With a force-push (yes, sometimes it's unavoidable)
> > I would have to go through the whole patch again.

I think that can be fine momentarily / depending on people but I don't think
it's a good idea in general.  Usually a PR only has one person contributing
substantial changes but multiple reviewers who don't care about historical
artifacts.

We should optimize for the reader -- which includes the author; when I send
a new version of nontrivial patch series, I usually try to read through the
entire series again. This doesn't really work if commits are not ordered
properly. If I check every line in-order, I'll confused by errors that are fixed in
a later commit.

Solving the tooling problem with `git range-diff` (or `jj interdiff`) should
cover many use cases.

I like how the Git project does it (see https://public-inbox.org/git/)
but obviously they have a different setup (only one maintainer is
allowed to merge, so things must be optimized for the reviewer).
One thing to note is that they include in each resubmission of the same patch series
the range-diff *and* prose descriptions of what changed.

So it's definitely possible to have both a simple diff to the previous
version but still keep the history atomic and monotonic.

> well, to me this sounds just backwards, like typical workaround
> thinking for bad tooling. squashing the fixups is a potentially
> error-prone process (the necessary reordering may cause conflicts),
> so it shouldn't be delayed.

In parts it's also Git itself; I think Jujutsu does a great job at
nudging beginners in a good direction.

> and of course gerrit has an answer to
> that: diffs between patchsets (versions of a change, or in practice,
> actual commits), also called inter-diffs, are a rather central
> element of the workflow.
> 
> i've yet to see a developer with
> high standards who has looked back.

I'm not really involved in mc but I'd love to try such a system that
encourages this.

I think it's absolutely the right choice to offer github (for 95%
of the population) but it can be nice to allow alternatives.

author_association: CONTRIBUTOR
user: login: zyv
created_at: 2025-03-07T09:17:15Z
body: > > One big advantage I see with `fixup` is that it's naturally easy to see which comments have been fixed and which have not. With a force-push (yes, sometimes it's unavoidable) I would have to go through the whole patch again.
> 
> well, to me this sounds just backwards, like typical workaround thinking for bad tooling. squashing the fixups is a potentially error-prone process (the necessary reordering may cause conflicts), so it shouldn't be delayed. [...]

I think it depends on how much development you do, how big the reviews are, how many comments of what kind you get, etc.

In my previous endaviours, it was either a few "big" issues with the PR, or a reasonable number of small things to fix before the merge.

For big issues, force-pushing was fine, because I could take a fresh look at the whole new patch, and minor comments could be left until the end.

For small issues, the fixup was very convenient. You leave 5 comments, get 5 fixups of a few lines each, tick them off, start an automatic rebase process that is almost guaranteed to end without conflict (unless a wrong commit was fixed or something), and that's it.

Polishing series of patches for large projects like the kernel or Qt is a completely different story. Of course, fixups are an inadequate tool for this. But these projects have more than 1-2 people reviewing them, the reviewers have time, the contributors have time and motivation to do anything at all, learning non-trivial tools is definitely worth the benefits, etc.

I can't see how that applies to mc in its current form, with its current team and contributors. Maybe if I were working on the kernel, git or Qt I'd feel differently, because that's what I do anyway, but I'm not, not even close.

So PRs with fixups seem to me to be the sweet spot right now in terms of being better than nothing, but very light. And of course even that seems to be too much for all the previous submitters I know of.

> seriously, just import mc to gerrithub (i would do that myself, but you'd need to give me full workflow permissions for the GH project) and import the pending PRs (after squashing the fixups), and see how it looks in practice. i've yet to see a developer with high standards who has looked back. (it occurs to me that this would have been a good argument for converting the tickets with associated branches to PRs.)

Can't you just fork mc to your account, set it up and make a demo if you're willing to put the work in? Why do you have to do it with the main organisation?
author_association: MEMBER
user: login: zyv
created_at: 2025-03-07T09:29:04Z
body: > > I have made it so that the PRs cannot be merged unless they are
> > up-to-date on master and properly rebased (for which I have implemented
> > a /rebase command).
> 
> Note that CI tests should typically run on a merge commit implicitly created by the CI (not sure what's the default in github), meaning that an up-to-date version will be tested, even if the PR branch is old.
> 
> After that suceeds, a `gh pr merge --rebase <PR-URL>` should result in the same Git tree unless something else merged in the meantime.

That's exactly how I set it up. Moreover, if something else has been merged in the meantime, the merge will be aborted.

> > > So I hope we can keep our working style of committing fixups (instead
> > > of mostly force-pushing series of patches) and then rebasing and
> > > merging with one command.
> 
> I have no horse in this race but I'm strongly in favor of cleaning up history early and often.
> 
> I don't think it's right to optimize for the hypothetical reviewer that has good enough memory of a previous version of a patch series to be able to benefit from multiple out-of-context commits added on top.

Well, I think if you have a 1.5 reviewer and close to zero contributors, it's absolutely right to optimize for what the reviewers find convenient.

I tried to explain this above, but in the end I don't think it really matters. I'm not against other workflows, and I'm open to learning and changing mine, but otherwise I prefer what I think is optimal for me under the current circumstances.

> > > One big advantage I see with fixup is
> > > that it's naturally easy to see which comments have been fixed and
> > > which have not. With a force-push (yes, sometimes it's unavoidable)
> > > I would have to go through the whole patch again.
> 
> I think that can be fine momentarily / depending on people but I don't think it's a good idea in general. Usually a PR only has one person contributing substantial changes but multiple reviewers who don't care about historical artifacts.

Yeah, that's exactly the opposite of what we have here :)

> Solving the tooling problem with `git range-diff` (or `jj interdiff`) should cover many use cases.

I will take a look at `range-diff`, thanks! Also, `jj` has been on my list for a long time, but I could never find the time to check it out... even more shameful now that I got to know the authors by chance, and I actually think very highly of the project.
 
> I like how the Git project does it (see https://public-inbox.org/git/) but obviously they have a different setup (only one maintainer is allowed to merge, so things must be optimized for the reviewer). One thing to note is that they include in each resubmission of the same patch series the range-diff _and_ prose descriptions of what changed.
> 
> So it's definitely possible to have both a simple diff to the previous version but still keep the history atomic and monotonic.

Looks a lot like the kernel workflow... somewhat unsurprising ;)
author_association: MEMBER
user: login: krobelus
created_at: 2025-03-07T10:56:51Z
body: > For small issues, the fixup was very convenient. You leave 5 comments, get 5 fixups of a few lines each, tick them off

I think range-diff allows the same workflow, and it also
1. guarantees that fixups are ordered
2. shows the fixups in their original context (so can tell if the surrounding code is new or not)
3. shows changes to commit messages

> But these projects have more than 1-2 people reviewing them, the
> reviewers have time, the contributors have time and motivation to
> do anything at all, learning non-trivial tools is definitely worth
> the benefits, etc.

I'm happy to follow any workflow that helps.

But to give a concrete example for why I prefer squashing early even
if I don't expect anyone else to read my code.

For my most recent push, I wanted to do another pass over my changes.
Specifically I wanted to read all changes except for the boring/noisy
code-movement commit, and also excluding anything that was merged in
from master on this branch.

I think that's a reasonable want (also post-merge, in case we found
a problem with these changes) -- so I'd strongly prefer to not squash
the code-movement commit with logic changes.

But the Git command to do so gets a bit complicated, mainly 
because it needs to get rid of the interleaved merge commit
by creating a a fake merge:

	code_movement_commit=20e6d84f9dde2949d4840dfcf4e180f433c9a465
	git diff $(git merge-tree $(git log -1 --merges --format=%H)^2 $code_movement_commit)

and of course a single "diff" invocation doesn't work well if there are multiple nontrivial patches

author_association: CONTRIBUTOR
user: login: ossilator
created_at: 2025-03-07T11:04:59Z
body: > I think it depends on how much development you do, how big the reviews are, how many comments of what kind you get, etc. [...]

honestly, you're overthinking it. project and contribution size don't matter at all. once you get into the gerrit workflow, you just want to use it for everything, because it's so natural and efficient.

for contributors it's a bit of a hurdle, because they need to get into history polishing in the first place. but my perspective is that this is actually a useful people filter.

> Can't you just fork mc to your account, set it up and make a demo if you're willing to put the work in? Why do you have to do it with the main organisation?

i can, but it seems counter-productive to me. if you actually take a look at my "clone" and then want to use it, then you'd have to replicate my steps and your experiments (which should be an actual live review to be realistic), which is a total waste. conversely, if you do the experiment yourself and find it to be garbage, then nothing is lost by doing it in the main org, as it can be just deleted afterwards.
author_association: CONTRIBUTOR
user: login: zyv
created_at: 2025-03-07T17:52:55Z
body: > > I think it depends on how much development you do, how big the reviews are, how many comments of what kind you get, etc. [...]
> 
> honestly, you're overthinking it. project and contribution size don't matter at all. once you get into the gerrit workflow, you just want to use it for everything, because it's so natural and efficient.
> 
> for contributors it's a bit of a hurdle, because they need to get into history polishing in the first place. but my perspective is that this is actually a useful people filter.
>
> > Can't you just fork mc to your account, set it up and make a demo if you're willing to put the work in? Why do you have to do it with the main organisation?
> 
> i can, but it seems counter-productive to me. if you actually take a look at my "clone" and then want to use it, then you'd have to replicate my steps and your experiments (which should be an actual live review to be realistic), which is a total waste. conversely, if you do the experiment yourself and find it to be garbage, then nothing is lost by doing it in the main org, as it can be just deleted afterwards.

I found a project that seems to use Gerrithub and read up on their process:

https://github.com/cue-lang/cue/blob/master/CONTRIBUTING.md

Now I understand better what you are saying. I'm afraid the people filter aspect has filtered me out at the moment. I kind of see the appeal, but the amount of overhead seems too crazy at the moment.

Maybe I'll come back to it one day when I've solved more of what I think are pressing problems...
author_association: MEMBER
user: login: zyv
created_at: 2025-03-08T10:39:31Z
body: @krobelus Thanks for your contribution, it was a wild ride!
author_association: MEMBER