Keep ctrl+c and ctrl+d working with the keyboard protocol on

94e4cad8e703 · AtlantisPleb · · parent 8fe785ea4067

Keep ctrl+c and ctrl+d working with the keyboard protocol on

Asking the terminal to disambiguate escape codes bought shift+enter and cost
every control key. With the protocol on, ctrl+c arrives as `\x1b[99;5u` and
ctrl+d as `\x1b[100;5u` rather than as `\x03` and `\x04`, and this console reads
only the bytes — so it stopped being quittable, which is a far worse trade than
the one it was making.

A protocol sequence for a key this interface binds is now decoded back to the
byte it stands for, and the existing handlers do the rest. One set of handlers
for both spellings rather than two that can disagree.

The first decoder went too far: shift+tab is `\x1b[9;2u`, and reading any tab
code as a bare tab cycled the model instead of the reasoning level. Only an
unmodified tab decodes; shift+tab stays the separate key it is.

Three tests cover both spellings of each, and all three fail with the decoder
removed.

422 tests pass.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

2 files changed, +95 -1

packages/openagents-cli/src/coder-ui.ts modified +41 -1

@@ -212,6 +212,38 @@ function truncate(text: string, width: number): string {

212 212
 * bytes so far could still be the start of one. Covers CSI (`\x1b[…final`),
213 213
 * SS3 (`\x1bO…`), and the `\x1b[…~` forms that carry PageUp and PageDown.
214 214
 */
215
/**
216
 * The legacy byte a keyboard-protocol sequence stands for, if any.
217
 *
218
 * Asking the terminal to disambiguate escape codes buys shift+enter, and costs
219
 * every control key: with the protocol on, ctrl+c arrives as `\x1b[99;5u`
220
 * rather than as `\x03`, so a console that reads only the byte stops being
221
 * quittable. Decoding back to the byte keeps one set of handlers for both
222
 * spellings rather than two that can disagree.
223
 *
224
 * Only what this interface actually binds. An unrecognized sequence is left
225
 * alone for the caller to handle or ignore.
226
 */
227
function controlFromKeyboardProtocol(sequence: string): string | undefined {
228
  const match = /^\x1b\[(\d+)(?:;(\d+))?u$/.exec(sequence);
229
  if (match === null) return undefined;
230
231
  const code = Number(match[1]);
232
  // The modifier is a bitfield offset by one: 1 is none, 5 is ctrl, 2 is shift.
233
  const modifiers = Number(match[2] ?? "1") - 1;
234
  const ctrl = (modifiers & 4) !== 0;
235
236
  if (ctrl && code >= 97 && code <= 122) {
237
    // ctrl+a is 1, ctrl+c is 3, and so on down the alphabet.
238
    return String.fromCharCode(code - 96);
239
  }
240
  // Tab with no modifier at all. Shift+tab is a different key here and is
241
  // matched further down; decoding it to a bare tab cycled the model instead of
242
  // the reasoning level.
243
  if (modifiers === 0 && code === 9) return "\t";
244
  return undefined;
245
}
246
215 247
function matchEscapeSequence(text: string, index: number): string | undefined {
216 248
  if (text[index] !== "\x1b") return undefined;
217 249
  const second = text[index + 1];

@@ -793,7 +825,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

793 825
        clearTimeout(escapeTimer);
794 826
        escapeTimer = undefined;
795 827
      }
796
      const text = pendingEscape + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
828
      let text = pendingEscape + (typeof chunk === "string" ? chunk : chunk.toString("utf8"));
797 829
      pendingEscape = "";
798 830
      let index = 0;
799 831
      let dirty = false;

@@ -870,6 +902,14 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

870 902
            dirty = false;
871 903
            continue;
872 904
          }
905
          // A control key in its protocol spelling is handled as the byte it
906
          // stands for, so ctrl+c and ctrl+d keep working with the protocol on.
907
          const asControl = controlFromKeyboardProtocol(sequence);
908
          if (asControl !== undefined) {
909
            text = text.slice(0, index) + asControl + text.slice(index);
910
            continue;
911
          }
912
873 913
          // Enter reported through the keyboard protocol: `13` is the key and
874 914
          // the second parameter is the modifier, where 2 is shift. Shift+enter
875 915
          // queues; enter, with or without other modifiers, steers.
packages/openagents-cli/test/coder-ui.test.ts modified +54

@@ -848,3 +848,57 @@ describe("changing how hard the model thinks", () => {

848 848
    expect(session.snapshot().reasoning).toBeUndefined();
849 849
  });
850 850
});
851
852
describe("quitting with the keyboard protocol on", () => {
853
  it("quits on ctrl+d in either spelling", async () => {
854
    for (const key of ["\x04", "\x1b[100;5u"]) {
855
      const stdin = new FakeIn();
856
      const stdout = new FakeOut();
857
      const session = new CoderSession(source([]), "repo", "main");
858
      const running = runCoderUi(session, {
859
        stdin: stdin as unknown as NodeJS.ReadStream,
860
        stdout: stdout as unknown as NodeJS.WriteStream,
861
      });
862
863
      stdin.emit("data", key);
864
865
      // Asking the terminal to disambiguate escape codes buys shift+enter and
866
      // costs every control key: ctrl+d arrives as `\x1b[100;5u`, and a console
867
      // that reads only the byte stops being quittable.
868
      await expect(running).resolves.toBe(0);
869
    }
870
  });
871
872
  it("stops on ctrl+c in either spelling", async () => {
873
    for (const key of ["\x03", "\x1b[99;5u"]) {
874
      const stdin = new FakeIn();
875
      const stdout = new FakeOut();
876
      const session = new CoderSession(source([]), "repo", "main");
877
      const running = runCoderUi(session, {
878
        stdin: stdin as unknown as NodeJS.ReadStream,
879
        stdout: stdout as unknown as NodeJS.WriteStream,
880
      });
881
882
      stdin.emit("data", key);
883
884
      await expect(running).resolves.toBe(130);
885
    }
886
  });
887
888
  it("leaves an ordinary tab meaning tab", async () => {
889
    const stdin = new FakeIn();
890
    const stdout = new FakeOut();
891
    const session = new CoderSession(switchable([]), "repo", "main");
892
    const running = runCoderUi(session, {
893
      stdin: stdin as unknown as NodeJS.ReadStream,
894
      stdout: stdout as unknown as NodeJS.WriteStream,
895
    });
896
897
    const before = session.snapshot().model;
898
    stdin.emit("data", "\x1b[9u");
899
    expect(session.snapshot().model).not.toBe(before);
900
901
    stdin.emit("data", "\x04");
902
    await running;
903
  });
904
});

This page updates live while a promote is in flight · changelog