Steer on enter, queue on shift+enter

fa7e8405607e · AtlantisPleb · · parent 06f0b332fa21

Steer on enter, queue on shift+enter

Steering is the common case — a reader typing while the model works usually
wants it read now, not after the thing they are trying to redirect has finished
— so it is the unmodified key. Shift+enter asks for the other one.

The terminal has to be willing to tell them apart. Shift+enter arrives as the
same carriage return as enter unless the keyboard protocol is on, so the
interface turns on flag 1, disambiguate escape codes, and turns it off on the
way out: a terminal still reporting this after the session has gone is one the
next program has to cope with. Ordinary text is unaffected — only keys that were
already ambiguous change shape — and a terminal that ignores the request leaves
enter doing the default, which is the one worth having.

Enter is then `\x1b[13u` with a modifier parameter, where 2 is shift. Terminals
that do not speak the protocol but send escape-then-return for shift or alt
enter are read as the deliberate key too: an escape with a return immediately
behind it is not something anyone types by accident. That check has to come
before the bare escape is taken as an interrupt, or the escape half of it stops
the turn the reader was steering.

The hint bar says which key does which while a turn runs, because a promise the
reader cannot see is one they will not use.

398 tests pass, including that enter and both spellings of shift+enter reach the
session as the modes they claim.

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-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

3 files changed, +106 -10

packages/openagents-cli/src/coder-session.ts modified +6 -5

@@ -461,7 +461,7 @@ export class CoderSession {

461 461
   * prompt that lands after an interruption is a prompt the user did not mean
462 462
   * to send.
463 463
   */
464
  async submit(prompt: string): Promise<void> {
464
  async submit(prompt: string, mode: "steer" | "queue" = "steer"): Promise<void> {
465 465
    if (prompt.trim().length === 0) return;
466 466
467 467
    // Delegation is not a turn: it does not go to the model, it does not block

@@ -518,10 +518,11 @@ export class CoderSession {

518 518
    if (this.controller !== undefined) {
519 519
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
520 520
521
      // Steering first: a source that runs a loop of model calls can read this
522
      // at its next step, so the model sees it while it is still working. A
523
      // source that cannot holds it until the turn ends instead of dropping it.
524
      if (this.source.steer?.(prompt) === true) {
521
      // Steering by default: a source that runs a loop of model calls reads
522
      // this at its next step, so the model sees it while it is still working.
523
      // A reader who wants the turn finished first asks for `queue`, and a
524
      // source that cannot steer holds it to the end either way.
525
      if (mode === "steer" && this.source.steer?.(prompt) === true) {
525 526
        this.notice("Steering: the model reads this at its next step.");
526 527
        this.emit();
527 528
        return;
packages/openagents-cli/src/coder-ui.ts modified +42 -5

@@ -46,6 +46,18 @@ const ERASE_LINE = "\x1b[K";

46 46
 * alternate screen is up. It costs one escape sequence and, unlike mouse
47 47
 * reporting, leaves the terminal's own text selection alone.
48 48
 */
49
/**
50
 * Ask the terminal to report modified keys unambiguously.
51
 *
52
 * Flag 1 of the keyboard protocol — disambiguate escape codes. Without it
53
 * shift+enter arrives as the same carriage return as enter, and the two cannot
54
 * be told apart at all. Ordinary text is unaffected; only keys that were
55
 * already ambiguous change shape, and a terminal that does not implement this
56
 * ignores it, which leaves enter doing the default and costs nothing.
57
 */
58
const KEYS_DISAMBIGUATE_ON = "\x1b[>1u";
59
const KEYS_DISAMBIGUATE_OFF = "\x1b[<u";
60
49 61
const ALT_SCROLL_ON = "\x1b[?1007h";
50 62
const ALT_SCROLL_OFF = "\x1b[?1007l";
51 63

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

295 307
      stdout.off("resize", onResize);
296 308
      if (stdin.isTTY) stdin.setRawMode(false);
297 309
      stdin.pause();
298
      write(CURSOR_SHOW + ALT_SCROLL_OFF + ALT_SCREEN_OFF);
310
      write(CURSOR_SHOW + KEYS_DISAMBIGUATE_OFF + ALT_SCROLL_OFF + ALT_SCREEN_OFF);
299 311
      resolve(exitCode);
300 312
    };
301 313

@@ -595,7 +607,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

595 607
      // interrupt" while idle, where there was nothing to interrupt.
596 608
      const keys: Hint[] = [];
597 609
      if (snapshot.running) {
598
        keys.push({ text: "esc to interrupt" }, { text: "ctrl+c to stop" });
610
        keys.push(
611
          { text: "enter to steer" },
612
          { text: "shift+enter to queue" },
613
          { text: "esc to interrupt" },
614
        );
599 615
      } else {
600 616
        keys.push({ text: "enter to send" });
601 617
        if (composer.length > 0) keys.push({ text: "esc to clear" });

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

661 677
      anchor = next >= maxStart ? undefined : next;
662 678
    };
663 679
664
    const submit = () => {
680
    const submit = (mode: "steer" | "queue" = "steer") => {
665 681
      const prompt = composer;
666 682
      composer = "";
667 683
      anchor = undefined;

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

715 731
        return;
716 732
      }
717 733
718
      void session.submit(prompt).finally(() => {
734
      void session.submit(prompt, mode).finally(() => {
719 735
        if (ticker !== undefined) {
720 736
          clearInterval(ticker);
721 737
          ticker = undefined;

@@ -828,11 +844,32 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

828 844
            break;
829 845
          }
830 846
          index += sequence.length;
847
          // Before the bare escape is taken as an interrupt: some terminals
848
          // send escape then return for shift or alt enter, and an escape with
849
          // a return immediately behind it is not something a reader types by
850
          // accident.
851
          if (sequence === "\x1b" && text[index] === "\r") {
852
            index += 1;
853
            submit("queue");
854
            dirty = false;
855
            continue;
856
          }
831 857
          if (sequence === "\x1b") {
832 858
            onEscape();
833 859
            dirty = false;
834 860
            continue;
835 861
          }
862
          // Enter reported through the keyboard protocol: `13` is the key and
863
          // the second parameter is the modifier, where 2 is shift. Shift+enter
864
          // queues; enter, with or without other modifiers, steers.
865
          const enter = /^\x1b\[13(?:;(\d+))?u$/.exec(sequence);
866
          if (enter !== null) {
867
            if (!session.running || composer.length > 0) {
868
              submit(enter[1] === "2" ? "queue" : "steer");
869
            }
870
            dirty = false;
871
            continue;
872
          }
836 873
          const page = Math.max(1, viewport - 1);
837 874
          if (sequence === "\x1b[5~") scrollBy(-page);
838 875
          else if (sequence === "\x1b[6~") scrollBy(page);

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

956 993
957 994
    const unsubscribe = session.onChange(render);
958 995
959
    write(ALT_SCREEN_ON + ALT_SCROLL_ON);
996
    write(ALT_SCREEN_ON + ALT_SCROLL_ON + KEYS_DISAMBIGUATE_ON);
960 997
    if (stdin.isTTY) stdin.setRawMode(true);
961 998
    stdin.resume();
962 999
    stdin.setEncoding("utf8");
packages/openagents-cli/test/coder-ui.test.ts modified +58

@@ -656,6 +656,64 @@ describe("typing while a turn is running", () => {

656 656
    await running;
657 657
  });
658 658
659
660
  it("steers on enter and queues on shift+enter", async () => {
661
    const stdin = new FakeIn();
662
    const stdout = new FakeOut();
663
    const modes: string[] = [];
664
    const { source: paused, release } = held();
665
    const session = new CoderSession(paused, "repo", "main");
666
    const realSubmit = session.submit.bind(session);
667
    session.submit = (prompt: string, mode?: "steer" | "queue") => {
668
      modes.push(mode ?? "steer");
669
      return realSubmit(prompt, mode);
670
    };
671
    const running = runCoderUi(session, {
672
      stdin: stdin as unknown as NodeJS.ReadStream,
673
      stdout: stdout as unknown as NodeJS.WriteStream,
674
    });
675
676
    const turn = session.submit("go");
677
    await new Promise((resolve) => setTimeout(resolve, 0));
678
679
    stdin.emit("data", "one");
680
    stdin.emit("data", "\r");
681
    // Shift+enter, as the keyboard protocol reports it: key 13, modifier 2.
682
    stdin.emit("data", "two");
683
    stdin.emit("data", "\x1b[13;2u");
684
    // And as the terminals that do not speak the protocol send it.
685
    stdin.emit("data", "three");
686
    stdin.emit("data", "\x1b\r");
687
688
    expect(modes).toEqual(["steer", "steer", "queue", "queue"]);
689
690
    release();
691
    await turn;
692
    stdin.emit("data", "\x04");
693
    await running;
694
  });
695
696
  it("asks the terminal to tell enter and shift+enter apart, and stops asking on the way out", async () => {
697
    const stdin = new FakeIn();
698
    const stdout = new FakeOut();
699
    const session = new CoderSession(source([]), "repo", "main");
700
    const running = runCoderUi(session, {
701
      stdin: stdin as unknown as NodeJS.ReadStream,
702
      stdout: stdout as unknown as NodeJS.WriteStream,
703
    });
704
705
    // Without this they arrive as the same carriage return and cannot be told
706
    // apart at all.
707
    expect(stdout.written).toContain("\x1b[>1u");
708
709
    stdin.emit("data", "\x04");
710
    await running;
711
712
    // Left as it was found: a terminal still reporting this after the session
713
    // has gone is a terminal the next program has to cope with.
714
    expect(stdout.written).toContain("\x1b[<u");
715
  });
716
659 717
  it("queues ordinary text and sends it when the turn ends", async () => {
660 718
    const stdin = new FakeIn();
661 719
    const stdout = new FakeOut();

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