@teammates/consolonia 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { Writable } from "node:stream";
2
- import { beforeEach, describe, expect, it } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
  import * as esc from "../ansi/esc.js";
4
4
  import { AnsiOutput } from "../ansi/output.js";
5
5
  import { stripAnsi, truncateAnsi, visibleLength } from "../ansi/strip.js";
6
+ import { detectTerminal } from "../ansi/terminal-env.js";
6
7
  // ── Helpers ────────────────────────────────────────────────────────
7
8
  const ESC = "\x1b[";
8
9
  /** Build a mock writable stream that accumulates output to a string. */
@@ -123,11 +124,11 @@ describe("esc", () => {
123
124
  expect(esc.bracketedPasteOn).toBe(`${ESC}?2004h`);
124
125
  expect(esc.bracketedPasteOff).toBe(`${ESC}?2004l`);
125
126
  });
126
- it("mouseTrackingOn enables button-event tracking and SGR mode", () => {
127
- expect(esc.mouseTrackingOn).toBe(`${ESC}?1003h${ESC}?1006h`);
127
+ it("mouseTrackingOn enables all mouse tracking modes", () => {
128
+ expect(esc.mouseTrackingOn).toBe(`${ESC}?1000h${ESC}?1003h${ESC}?1005h${ESC}?1006h${ESC}?1015h${ESC}?1016h`);
128
129
  });
129
- it("mouseTrackingOff disables SGR mode and button-event tracking", () => {
130
- expect(esc.mouseTrackingOff).toBe(`${ESC}?1006l${ESC}?1003l`);
130
+ it("mouseTrackingOff disables all mouse tracking modes", () => {
131
+ expect(esc.mouseTrackingOff).toBe(`${ESC}?1016l${ESC}?1015l${ESC}?1006l${ESC}?1005l${ESC}?1003l${ESC}?1000l`);
131
132
  });
132
133
  });
133
134
  describe("setTitle", () => {
@@ -518,3 +519,208 @@ describe("AnsiOutput", () => {
518
519
  });
519
520
  });
520
521
  });
522
+ // ═══════════════════════════════════════════════════════════════════
523
+ // terminal-env.ts
524
+ // ═══════════════════════════════════════════════════════════════════
525
+ describe("detectTerminal", () => {
526
+ const origEnv = { ...process.env };
527
+ const origPlatform = process.platform;
528
+ const origIsTTY = process.stdout.isTTY;
529
+ afterEach(() => {
530
+ // Restore environment
531
+ for (const key of Object.keys(process.env)) {
532
+ if (!(key in origEnv))
533
+ delete process.env[key];
534
+ }
535
+ Object.assign(process.env, origEnv);
536
+ Object.defineProperty(process, "platform", { value: origPlatform });
537
+ Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true });
538
+ });
539
+ function setEnv(overrides) {
540
+ for (const [k, v] of Object.entries(overrides)) {
541
+ if (v === undefined)
542
+ delete process.env[k];
543
+ else
544
+ process.env[k] = v;
545
+ }
546
+ }
547
+ it("returns pipe caps when stdout is not a TTY", () => {
548
+ Object.defineProperty(process.stdout, "isTTY", { value: false, writable: true });
549
+ const caps = detectTerminal();
550
+ expect(caps.isTTY).toBe(false);
551
+ expect(caps.mouse).toBe(false);
552
+ expect(caps.alternateScreen).toBe(false);
553
+ expect(caps.name).toBe("pipe");
554
+ });
555
+ it("detects Windows Terminal via WT_SESSION", () => {
556
+ Object.defineProperty(process, "platform", { value: "win32" });
557
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
558
+ setEnv({ WT_SESSION: "some-guid" });
559
+ const caps = detectTerminal();
560
+ expect(caps.name).toBe("windows-terminal");
561
+ expect(caps.sgrMouse).toBe(true);
562
+ expect(caps.truecolor).toBe(true);
563
+ });
564
+ it("detects VS Code terminal on Windows", () => {
565
+ Object.defineProperty(process, "platform", { value: "win32" });
566
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
567
+ setEnv({ WT_SESSION: undefined, TERM_PROGRAM: "vscode" });
568
+ const caps = detectTerminal();
569
+ expect(caps.name).toBe("vscode");
570
+ expect(caps.sgrMouse).toBe(true);
571
+ });
572
+ it("detects ConEmu on Windows", () => {
573
+ Object.defineProperty(process, "platform", { value: "win32" });
574
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
575
+ setEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined, ConEmuPID: "1234" });
576
+ const caps = detectTerminal();
577
+ expect(caps.name).toBe("conemu");
578
+ });
579
+ it("detects mintty via TERM + MSYSTEM", () => {
580
+ Object.defineProperty(process, "platform", { value: "win32" });
581
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
582
+ setEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined, ConEmuPID: undefined, TERM: "xterm-256color", MSYSTEM: "MINGW64" });
583
+ const caps = detectTerminal();
584
+ expect(caps.name).toBe("mintty");
585
+ });
586
+ it("detects tmux on Unix", () => {
587
+ Object.defineProperty(process, "platform", { value: "linux" });
588
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
589
+ setEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "screen-256color" });
590
+ const caps = detectTerminal();
591
+ expect(caps.name).toBe("tmux");
592
+ expect(caps.sgrMouse).toBe(true);
593
+ });
594
+ it("detects GNU screen with limited caps", () => {
595
+ Object.defineProperty(process, "platform", { value: "linux" });
596
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
597
+ setEnv({ TMUX: undefined, TERM: "screen", TERM_PROGRAM: undefined, ITERM_SESSION_ID: undefined });
598
+ const caps = detectTerminal();
599
+ expect(caps.name).toBe("screen");
600
+ expect(caps.sgrMouse).toBe(false);
601
+ expect(caps.bracketedPaste).toBe(false);
602
+ });
603
+ it("detects iTerm2", () => {
604
+ Object.defineProperty(process, "platform", { value: "darwin" });
605
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
606
+ setEnv({ TMUX: undefined, TERM: "xterm-256color", TERM_PROGRAM: "iTerm.app" });
607
+ const caps = detectTerminal();
608
+ expect(caps.name).toBe("iterm2");
609
+ expect(caps.truecolor).toBe(true);
610
+ });
611
+ it("detects xterm-compatible with COLORTERM truecolor", () => {
612
+ Object.defineProperty(process, "platform", { value: "linux" });
613
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
614
+ setEnv({ TMUX: undefined, TERM: "xterm-256color", TERM_PROGRAM: undefined, ITERM_SESSION_ID: undefined, COLORTERM: "truecolor" });
615
+ const caps = detectTerminal();
616
+ expect(caps.truecolor).toBe(true);
617
+ expect(caps.color256).toBe(true);
618
+ });
619
+ it("detects dumb terminal with minimal caps", () => {
620
+ Object.defineProperty(process, "platform", { value: "linux" });
621
+ Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true });
622
+ setEnv({ TMUX: undefined, TERM: "dumb", TERM_PROGRAM: undefined, ITERM_SESSION_ID: undefined });
623
+ const caps = detectTerminal();
624
+ expect(caps.name).toBe("dumb");
625
+ expect(caps.mouse).toBe(false);
626
+ expect(caps.alternateScreen).toBe(false);
627
+ });
628
+ });
629
+ // ═══════════════════════════════════════════════════════════════════
630
+ // esc.ts — environment-aware init/restore sequences
631
+ // ═══════════════════════════════════════════════════════════════════
632
+ describe("esc environment-aware sequences", () => {
633
+ /** Helper to build a TerminalCaps with overrides. */
634
+ function makeCaps(overrides = {}) {
635
+ return {
636
+ isTTY: true,
637
+ alternateScreen: true,
638
+ bracketedPaste: true,
639
+ mouse: true,
640
+ sgrMouse: true,
641
+ truecolor: true,
642
+ color256: true,
643
+ name: "test",
644
+ ...overrides,
645
+ };
646
+ }
647
+ describe("mouseOn / mouseOff", () => {
648
+ it("returns full mouse sequence when SGR is supported", () => {
649
+ const caps = makeCaps({ sgrMouse: true });
650
+ expect(esc.mouseOn(caps)).toBe(esc.mouseTrackingOn);
651
+ expect(esc.mouseOff(caps)).toBe(esc.mouseTrackingOff);
652
+ });
653
+ it("returns minimal mouse sequence when SGR is not supported", () => {
654
+ const caps = makeCaps({ sgrMouse: false });
655
+ const on = esc.mouseOn(caps);
656
+ expect(on).toContain("?1000h");
657
+ expect(on).toContain("?1003h");
658
+ expect(on).not.toContain("?1006h");
659
+ expect(on).not.toContain("?1015h");
660
+ });
661
+ it("returns empty string when mouse is not supported", () => {
662
+ const caps = makeCaps({ mouse: false });
663
+ expect(esc.mouseOn(caps)).toBe("");
664
+ expect(esc.mouseOff(caps)).toBe("");
665
+ });
666
+ });
667
+ describe("initSequence", () => {
668
+ it("includes all features for a full-caps terminal", () => {
669
+ const caps = makeCaps();
670
+ const seq = esc.initSequence(caps, { alternateScreen: true, mouse: true });
671
+ expect(seq).toContain(esc.alternateScreenOn);
672
+ expect(seq).toContain(esc.hideCursor);
673
+ expect(seq).toContain(esc.bracketedPasteOn);
674
+ expect(seq).toContain(esc.mouseTrackingOn);
675
+ expect(seq).toContain(esc.clearScreen);
676
+ });
677
+ it("skips alternate screen when not supported", () => {
678
+ const caps = makeCaps({ alternateScreen: false });
679
+ const seq = esc.initSequence(caps, { alternateScreen: true, mouse: false });
680
+ expect(seq).not.toContain(esc.alternateScreenOn);
681
+ });
682
+ it("skips alternate screen when app opts out", () => {
683
+ const caps = makeCaps();
684
+ const seq = esc.initSequence(caps, { alternateScreen: false, mouse: false });
685
+ expect(seq).not.toContain(esc.alternateScreenOn);
686
+ });
687
+ it("skips bracketed paste when not supported", () => {
688
+ const caps = makeCaps({ bracketedPaste: false });
689
+ const seq = esc.initSequence(caps, { alternateScreen: false, mouse: false });
690
+ expect(seq).not.toContain(esc.bracketedPasteOn);
691
+ });
692
+ it("skips mouse when app opts out even if caps support it", () => {
693
+ const caps = makeCaps();
694
+ const seq = esc.initSequence(caps, { alternateScreen: false, mouse: false });
695
+ expect(seq).not.toContain("?1000h");
696
+ });
697
+ it("returns empty string for non-TTY", () => {
698
+ const caps = makeCaps({ isTTY: false });
699
+ const seq = esc.initSequence(caps, { alternateScreen: true, mouse: true });
700
+ expect(seq).toBe("");
701
+ });
702
+ });
703
+ describe("restoreSequence", () => {
704
+ it("includes all restore features for a full-caps terminal", () => {
705
+ const caps = makeCaps();
706
+ const seq = esc.restoreSequence(caps, { alternateScreen: true, mouse: true });
707
+ expect(seq).toContain(esc.reset);
708
+ expect(seq).toContain(esc.mouseTrackingOff);
709
+ expect(seq).toContain(esc.bracketedPasteOff);
710
+ expect(seq).toContain(esc.showCursor);
711
+ expect(seq).toContain(esc.alternateScreenOff);
712
+ });
713
+ it("mirrors initSequence — skips what init skipped", () => {
714
+ const caps = makeCaps({ bracketedPaste: false, alternateScreen: false });
715
+ const seq = esc.restoreSequence(caps, { alternateScreen: true, mouse: false });
716
+ expect(seq).not.toContain(esc.bracketedPasteOff);
717
+ expect(seq).not.toContain(esc.alternateScreenOff);
718
+ expect(seq).not.toContain(esc.mouseTrackingOff);
719
+ });
720
+ it("returns empty string for non-TTY", () => {
721
+ const caps = makeCaps({ isTTY: false });
722
+ const seq = esc.restoreSequence(caps, { alternateScreen: true, mouse: true });
723
+ expect(seq).toBe("");
724
+ });
725
+ });
726
+ });
@@ -28,6 +28,9 @@ function collectEvents(data) {
28
28
  processor.destroy();
29
29
  return collected;
30
30
  }
31
+ function x10Seq(cb, x, y) {
32
+ return `\x1b[M${String.fromCharCode(cb + 32)}${String.fromCharCode(x + 32)}${String.fromCharCode(y + 32)}`;
33
+ }
31
34
  // =====================================================================
32
35
  // EscapeMatcher
33
36
  // =====================================================================
@@ -675,6 +678,137 @@ describe("MouseMatcher", () => {
675
678
  expect(results[results.length - 1]).toBe(MatchResult.Complete);
676
679
  });
677
680
  });
681
+ // ── URXVT sequences ────────────────────────────────────────────────
682
+ describe("URXVT sequences", () => {
683
+ it("parses URXVT left press at (9,19) from \\x1b[0;10;20M", () => {
684
+ matcher = new MouseMatcher();
685
+ expect(feedString(matcher, "\x1b[0;10;20M")).toBe(MatchResult.Complete);
686
+ const ev = matcher.flush();
687
+ expect(ev).not.toBeNull();
688
+ expect(ev.type).toBe("mouse");
689
+ if (ev.type === "mouse") {
690
+ expect(ev.event.button).toBe("left");
691
+ expect(ev.event.type).toBe("press");
692
+ expect(ev.event.x).toBe(9);
693
+ expect(ev.event.y).toBe(19);
694
+ }
695
+ });
696
+ it("parses URXVT right press", () => {
697
+ matcher = new MouseMatcher();
698
+ expect(feedString(matcher, "\x1b[2;5;5M")).toBe(MatchResult.Complete);
699
+ const ev = matcher.flush();
700
+ if (ev.type === "mouse") {
701
+ expect(ev.event.button).toBe("right");
702
+ expect(ev.event.type).toBe("press");
703
+ expect(ev.event.x).toBe(4);
704
+ expect(ev.event.y).toBe(4);
705
+ }
706
+ });
707
+ it("parses URXVT release (button bits = 3)", () => {
708
+ matcher = new MouseMatcher();
709
+ expect(feedString(matcher, "\x1b[3;10;20M")).toBe(MatchResult.Complete);
710
+ const ev = matcher.flush();
711
+ if (ev.type === "mouse") {
712
+ expect(ev.event.button).toBe("none");
713
+ expect(ev.event.type).toBe("release");
714
+ }
715
+ });
716
+ it("parses URXVT wheel up (cb=64)", () => {
717
+ matcher = new MouseMatcher();
718
+ expect(feedString(matcher, "\x1b[64;5;5M")).toBe(MatchResult.Complete);
719
+ const ev = matcher.flush();
720
+ if (ev.type === "mouse") {
721
+ expect(ev.event.button).toBe("none");
722
+ expect(ev.event.type).toBe("wheelup");
723
+ }
724
+ });
725
+ it("parses URXVT wheel down (cb=65)", () => {
726
+ matcher = new MouseMatcher();
727
+ expect(feedString(matcher, "\x1b[65;5;5M")).toBe(MatchResult.Complete);
728
+ const ev = matcher.flush();
729
+ if (ev.type === "mouse") {
730
+ expect(ev.event.button).toBe("none");
731
+ expect(ev.event.type).toBe("wheeldown");
732
+ }
733
+ });
734
+ it("parses URXVT motion with left button (cb=32)", () => {
735
+ matcher = new MouseMatcher();
736
+ expect(feedString(matcher, "\x1b[32;10;10M")).toBe(MatchResult.Complete);
737
+ const ev = matcher.flush();
738
+ if (ev.type === "mouse") {
739
+ expect(ev.event.type).toBe("move");
740
+ expect(ev.event.button).toBe("left");
741
+ }
742
+ });
743
+ it("parses URXVT shift+left press (cb=4)", () => {
744
+ matcher = new MouseMatcher();
745
+ expect(feedString(matcher, "\x1b[4;1;1M")).toBe(MatchResult.Complete);
746
+ const ev = matcher.flush();
747
+ if (ev.type === "mouse") {
748
+ expect(ev.event.button).toBe("left");
749
+ expect(ev.event.type).toBe("press");
750
+ expect(ev.event.shift).toBe(true);
751
+ expect(ev.event.ctrl).toBe(false);
752
+ expect(ev.event.alt).toBe(false);
753
+ }
754
+ });
755
+ it("parses URXVT with large coordinates (beyond X10 range)", () => {
756
+ matcher = new MouseMatcher();
757
+ expect(feedString(matcher, "\x1b[0;300;200M")).toBe(MatchResult.Complete);
758
+ const ev = matcher.flush();
759
+ if (ev.type === "mouse") {
760
+ expect(ev.event.button).toBe("left");
761
+ expect(ev.event.type).toBe("press");
762
+ expect(ev.event.x).toBe(299);
763
+ expect(ev.event.y).toBe(199);
764
+ }
765
+ });
766
+ it("rejects URXVT with wrong param count (2 params)", () => {
767
+ matcher = new MouseMatcher();
768
+ expect(feedString(matcher, "\x1b[0;10M")).toBe(MatchResult.NoMatch);
769
+ });
770
+ it("rejects URXVT with wrong param count (4 params)", () => {
771
+ matcher = new MouseMatcher();
772
+ expect(feedString(matcher, "\x1b[0;10;20;30M")).toBe(MatchResult.NoMatch);
773
+ });
774
+ });
775
+ describe("classic X10 sequences", () => {
776
+ it("parses left press from classic CSI M encoding", () => {
777
+ matcher = new MouseMatcher();
778
+ expect(feedString(matcher, x10Seq(0, 10, 20))).toBe(MatchResult.Complete);
779
+ const ev = matcher.flush();
780
+ expect(ev).not.toBeNull();
781
+ expect(ev.type).toBe("mouse");
782
+ if (ev.type === "mouse") {
783
+ expect(ev.event.button).toBe("left");
784
+ expect(ev.event.type).toBe("press");
785
+ expect(ev.event.x).toBe(9);
786
+ expect(ev.event.y).toBe(19);
787
+ }
788
+ });
789
+ it("parses release from classic CSI M encoding", () => {
790
+ matcher = new MouseMatcher();
791
+ expect(feedString(matcher, x10Seq(3, 10, 20))).toBe(MatchResult.Complete);
792
+ const ev = matcher.flush();
793
+ expect(ev).not.toBeNull();
794
+ expect(ev.type).toBe("mouse");
795
+ if (ev.type === "mouse") {
796
+ expect(ev.event.button).toBe("none");
797
+ expect(ev.event.type).toBe("release");
798
+ }
799
+ });
800
+ it("parses wheel down from classic CSI M encoding", () => {
801
+ matcher = new MouseMatcher();
802
+ expect(feedString(matcher, x10Seq(65, 5, 5))).toBe(MatchResult.Complete);
803
+ const ev = matcher.flush();
804
+ expect(ev).not.toBeNull();
805
+ expect(ev.type).toBe("mouse");
806
+ if (ev.type === "mouse") {
807
+ expect(ev.event.button).toBe("none");
808
+ expect(ev.event.type).toBe("wheeldown");
809
+ }
810
+ });
811
+ });
678
812
  });
679
813
  // =====================================================================
680
814
  // TextMatcher
@@ -853,6 +987,29 @@ describe("InputProcessor", () => {
853
987
  expect(events[0].event.y).toBe(0);
854
988
  }
855
989
  });
990
+ it("URXVT mouse sequences are recognized through parallel matching", () => {
991
+ // URXVT: \x1b[0;5;10M — left press at (4,9)
992
+ const events = collectEvents("\x1b[0;5;10M");
993
+ expect(events).toHaveLength(1);
994
+ expect(events[0].type).toBe("mouse");
995
+ if (events[0].type === "mouse") {
996
+ expect(events[0].event.button).toBe("left");
997
+ expect(events[0].event.type).toBe("press");
998
+ expect(events[0].event.x).toBe(4);
999
+ expect(events[0].event.y).toBe(9);
1000
+ }
1001
+ });
1002
+ it("classic X10 mouse sequences are recognized through parallel matching", () => {
1003
+ const events = collectEvents(x10Seq(0, 1, 1));
1004
+ expect(events).toHaveLength(1);
1005
+ expect(events[0].type).toBe("mouse");
1006
+ if (events[0].type === "mouse") {
1007
+ expect(events[0].event.button).toBe("left");
1008
+ expect(events[0].event.type).toBe("press");
1009
+ expect(events[0].event.x).toBe(0);
1010
+ expect(events[0].event.y).toBe(0);
1011
+ }
1012
+ });
856
1013
  it("handles text after a paste sequence", () => {
857
1014
  const events = collectEvents("\x1b[200~pasted\x1b[201~after");
858
1015
  expect(events).toHaveLength(6); // 1 paste + 5 chars
@@ -2,6 +2,7 @@
2
2
  * ANSI escape sequence constants and builder functions.
3
3
  * All functions return raw escape strings — nothing is written to stdout.
4
4
  */
5
+ import type { TerminalCaps } from "./terminal-env.js";
5
6
  export declare const reset = "\u001B[0m";
6
7
  export declare const bold = "\u001B[1m";
7
8
  export declare const dim = "\u001B[2m";
@@ -53,9 +54,52 @@ export declare const eraseLine = "\u001B[2K";
53
54
  export declare const bracketedPasteOn = "\u001B[?2004h";
54
55
  /** Disable bracketed paste mode. */
55
56
  export declare const bracketedPasteOff = "\u001B[?2004l";
56
- /** Enable SGR mouse tracking (any-event tracking + SGR extended coordinates). */
57
- export declare const mouseTrackingOn = "\u001B[?1003h\u001B[?1006h";
58
- /** Disable SGR mouse tracking. */
59
- export declare const mouseTrackingOff = "\u001B[?1006l\u001B[?1003l";
57
+ /**
58
+ * Enable mouse tracking with all supported reporting modes.
59
+ *
60
+ * Modes enabled (in order):
61
+ * ?1000h — Normal/VT200 click tracking (press + release)
62
+ * ?1003h — Any-event tracking (all mouse movement)
63
+ * ?1005h — UTF-8 coordinate encoding (extends X10 beyond col/row 223)
64
+ * ?1006h — SGR extended coordinates (";"-delimited decimals, M/m terminator)
65
+ * ?1015h — URXVT extended coordinates (decimal params, no "<" prefix)
66
+ * ?1016h — SGR-Pixels (same wire format as SGR, pixel coordinates)
67
+ *
68
+ * Terminals pick the highest mode they support. SGR is preferred by most
69
+ * modern terminals; URXVT and UTF-8 provide fallback for older ones.
70
+ */
71
+ export declare const mouseTrackingOn = "\u001B[?1000h\u001B[?1003h\u001B[?1005h\u001B[?1006h\u001B[?1015h\u001B[?1016h";
72
+ /**
73
+ * Disable all mouse tracking modes (reverse order of enable).
74
+ */
75
+ export declare const mouseTrackingOff = "\u001B[?1016l\u001B[?1015l\u001B[?1006l\u001B[?1005l\u001B[?1003l\u001B[?1000l";
60
76
  /** Set the terminal window title. */
61
77
  export declare function setTitle(title: string): string;
78
+ /**
79
+ * Mouse tracking sequence tailored to detected capabilities.
80
+ *
81
+ * When the terminal supports SGR, request all six modes so the terminal
82
+ * picks the highest one it handles. When SGR is not available (e.g. GNU
83
+ * screen), fall back to normal + any-event tracking only — UTF-8 and
84
+ * URXVT modes could confuse terminals that don't understand them.
85
+ */
86
+ export declare function mouseOn(caps: TerminalCaps): string;
87
+ /** Matching disable sequence for mouseOn(). */
88
+ export declare function mouseOff(caps: TerminalCaps): string;
89
+ /**
90
+ * Build the full terminal preparation sequence for the given environment.
91
+ *
92
+ * @param caps - Detected terminal capabilities
93
+ * @param opts - Which optional features the app requested
94
+ */
95
+ export declare function initSequence(caps: TerminalCaps, opts: {
96
+ alternateScreen: boolean;
97
+ mouse: boolean;
98
+ }): string;
99
+ /**
100
+ * Build the full terminal restore sequence (mirror of initSequence).
101
+ */
102
+ export declare function restoreSequence(caps: TerminalCaps, opts: {
103
+ alternateScreen: boolean;
104
+ mouse: boolean;
105
+ }): string;
package/dist/ansi/esc.js CHANGED
@@ -74,12 +74,94 @@ export const eraseLine = `${ESC}2K`;
74
74
  export const bracketedPasteOn = `${ESC}?2004h`;
75
75
  /** Disable bracketed paste mode. */
76
76
  export const bracketedPasteOff = `${ESC}?2004l`;
77
- /** Enable SGR mouse tracking (any-event tracking + SGR extended coordinates). */
78
- export const mouseTrackingOn = `${ESC}?1003h${ESC}?1006h`;
79
- /** Disable SGR mouse tracking. */
80
- export const mouseTrackingOff = `${ESC}?1006l${ESC}?1003l`;
77
+ /**
78
+ * Enable mouse tracking with all supported reporting modes.
79
+ *
80
+ * Modes enabled (in order):
81
+ * ?1000h — Normal/VT200 click tracking (press + release)
82
+ * ?1003h — Any-event tracking (all mouse movement)
83
+ * ?1005h — UTF-8 coordinate encoding (extends X10 beyond col/row 223)
84
+ * ?1006h — SGR extended coordinates (";"-delimited decimals, M/m terminator)
85
+ * ?1015h — URXVT extended coordinates (decimal params, no "<" prefix)
86
+ * ?1016h — SGR-Pixels (same wire format as SGR, pixel coordinates)
87
+ *
88
+ * Terminals pick the highest mode they support. SGR is preferred by most
89
+ * modern terminals; URXVT and UTF-8 provide fallback for older ones.
90
+ */
91
+ export const mouseTrackingOn = `${ESC}?1000h${ESC}?1003h${ESC}?1005h${ESC}?1006h${ESC}?1015h${ESC}?1016h`;
92
+ /**
93
+ * Disable all mouse tracking modes (reverse order of enable).
94
+ */
95
+ export const mouseTrackingOff = `${ESC}?1016l${ESC}?1015l${ESC}?1006l${ESC}?1005l${ESC}?1003l${ESC}?1000l`;
81
96
  // ── Window ──────────────────────────────────────────────────────────
82
97
  /** Set the terminal window title. */
83
98
  export function setTitle(title) {
84
99
  return `${OSC}0;${title}\x07`;
85
100
  }
101
+ // ── Environment-aware init/restore ─────────────────────────────────
102
+ /**
103
+ * Mouse tracking sequence tailored to detected capabilities.
104
+ *
105
+ * When the terminal supports SGR, request all six modes so the terminal
106
+ * picks the highest one it handles. When SGR is not available (e.g. GNU
107
+ * screen), fall back to normal + any-event tracking only — UTF-8 and
108
+ * URXVT modes could confuse terminals that don't understand them.
109
+ */
110
+ export function mouseOn(caps) {
111
+ if (!caps.mouse)
112
+ return "";
113
+ if (caps.sgrMouse)
114
+ return mouseTrackingOn;
115
+ // Minimal: normal click + any-event only
116
+ return `${ESC}?1000h${ESC}?1003h`;
117
+ }
118
+ /** Matching disable sequence for mouseOn(). */
119
+ export function mouseOff(caps) {
120
+ if (!caps.mouse)
121
+ return "";
122
+ if (caps.sgrMouse)
123
+ return mouseTrackingOff;
124
+ return `${ESC}?1003l${ESC}?1000l`;
125
+ }
126
+ /**
127
+ * Build the full terminal preparation sequence for the given environment.
128
+ *
129
+ * @param caps - Detected terminal capabilities
130
+ * @param opts - Which optional features the app requested
131
+ */
132
+ export function initSequence(caps, opts) {
133
+ if (!caps.isTTY)
134
+ return "";
135
+ let seq = "";
136
+ if (opts.alternateScreen && caps.alternateScreen) {
137
+ seq += alternateScreenOn;
138
+ }
139
+ seq += hideCursor;
140
+ if (caps.bracketedPaste) {
141
+ seq += bracketedPasteOn;
142
+ }
143
+ if (opts.mouse) {
144
+ seq += mouseOn(caps);
145
+ }
146
+ seq += clearScreen;
147
+ return seq;
148
+ }
149
+ /**
150
+ * Build the full terminal restore sequence (mirror of initSequence).
151
+ */
152
+ export function restoreSequence(caps, opts) {
153
+ if (!caps.isTTY)
154
+ return "";
155
+ let seq = reset;
156
+ if (opts.mouse) {
157
+ seq += mouseOff(caps);
158
+ }
159
+ if (caps.bracketedPaste) {
160
+ seq += bracketedPasteOff;
161
+ }
162
+ seq += showCursor;
163
+ if (opts.alternateScreen && caps.alternateScreen) {
164
+ seq += alternateScreenOff;
165
+ }
166
+ return seq;
167
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Terminal environment detection.
3
+ *
4
+ * Probes environment variables and process state to determine which
5
+ * terminal capabilities are available. Used by App to send only the
6
+ * escape sequences the host terminal actually supports.
7
+ */
8
+ export interface TerminalCaps {
9
+ /** Terminal is a TTY (not piped). */
10
+ isTTY: boolean;
11
+ /** Supports alternate screen buffer (?1049h). */
12
+ alternateScreen: boolean;
13
+ /** Supports bracketed paste mode (?2004h). */
14
+ bracketedPaste: boolean;
15
+ /** Supports escape-based mouse tracking (?1000h and above). */
16
+ mouse: boolean;
17
+ /** Supports SGR extended mouse encoding (?1006h). */
18
+ sgrMouse: boolean;
19
+ /** Supports truecolor (24-bit) SGR sequences. */
20
+ truecolor: boolean;
21
+ /** Supports 256-color SGR sequences. */
22
+ color256: boolean;
23
+ /** Detected terminal name (for diagnostics). */
24
+ name: string;
25
+ }
26
+ /**
27
+ * Detect terminal capabilities from the current environment.
28
+ *
29
+ * On Windows the main differentiator is whether we're running under
30
+ * Windows Terminal / ConPTY (full VT support) or legacy conhost
31
+ * (very limited escape handling). On Unix the TERM variable and
32
+ * TERM_PROGRAM give us enough signal.
33
+ */
34
+ export declare function detectTerminal(): TerminalCaps;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Terminal environment detection.
3
+ *
4
+ * Probes environment variables and process state to determine which
5
+ * terminal capabilities are available. Used by App to send only the
6
+ * escape sequences the host terminal actually supports.
7
+ */
8
+ // ── Detection ───────────────────────────────────────────────────────
9
+ /**
10
+ * Detect terminal capabilities from the current environment.
11
+ *
12
+ * On Windows the main differentiator is whether we're running under
13
+ * Windows Terminal / ConPTY (full VT support) or legacy conhost
14
+ * (very limited escape handling). On Unix the TERM variable and
15
+ * TERM_PROGRAM give us enough signal.
16
+ */
17
+ export function detectTerminal() {
18
+ const env = process.env;
19
+ const isTTY = !!process.stdout.isTTY;
20
+ if (!isTTY) {
21
+ return {
22
+ isTTY: false,
23
+ alternateScreen: false,
24
+ bracketedPaste: false,
25
+ mouse: false,
26
+ sgrMouse: false,
27
+ truecolor: false,
28
+ color256: false,
29
+ name: "pipe",
30
+ };
31
+ }
32
+ // ── Windows ─────────────────────────────────────────────────────
33
+ if (process.platform === "win32") {
34
+ // Windows Terminal sets WT_SESSION
35
+ if (env.WT_SESSION) {
36
+ return {
37
+ isTTY: true,
38
+ alternateScreen: true,
39
+ bracketedPaste: true,
40
+ mouse: true,
41
+ sgrMouse: true,
42
+ truecolor: true,
43
+ color256: true,
44
+ name: "windows-terminal",
45
+ };
46
+ }
47
+ // VS Code's integrated terminal (xterm.js)
48
+ if (env.TERM_PROGRAM === "vscode") {
49
+ return {
50
+ isTTY: true,
51
+ alternateScreen: true,
52
+ bracketedPaste: true,
53
+ mouse: true,
54
+ sgrMouse: true,
55
+ truecolor: true,
56
+ color256: true,
57
+ name: "vscode",
58
+ };
59
+ }
60
+ // ConEmu / Cmder
61
+ if (env.ConEmuPID) {
62
+ return {
63
+ isTTY: true,
64
+ alternateScreen: true,
65
+ bracketedPaste: true,
66
+ mouse: true,
67
+ sgrMouse: true,
68
+ truecolor: true,
69
+ color256: true,
70
+ name: "conemu",
71
+ };
72
+ }
73
+ // Mintty (Git Bash) — sets TERM=xterm*
74
+ if (env.TERM?.startsWith("xterm") && env.MSYSTEM) {
75
+ return {
76
+ isTTY: true,
77
+ alternateScreen: true,
78
+ bracketedPaste: true,
79
+ mouse: true,
80
+ sgrMouse: true,
81
+ truecolor: true,
82
+ color256: true,
83
+ name: "mintty",
84
+ };
85
+ }
86
+ // Fallback: modern Windows 10+ conhost with ConPTY has decent VT
87
+ // support, but mouse tracking can be unreliable. Enable everything
88
+ // and let the terminal silently ignore what it doesn't support.
89
+ return {
90
+ isTTY: true,
91
+ alternateScreen: true,
92
+ bracketedPaste: true,
93
+ mouse: true,
94
+ sgrMouse: true,
95
+ truecolor: true,
96
+ color256: true,
97
+ name: "conhost",
98
+ };
99
+ }
100
+ // ── Unix / macOS ────────────────────────────────────────────────
101
+ const term = env.TERM ?? "";
102
+ const termProgram = env.TERM_PROGRAM ?? "";
103
+ // tmux — full VT support, passes through SGR mouse
104
+ if (env.TMUX || term.startsWith("tmux") || term === "screen-256color") {
105
+ return {
106
+ isTTY: true,
107
+ alternateScreen: true,
108
+ bracketedPaste: true,
109
+ mouse: true,
110
+ sgrMouse: true,
111
+ truecolor: !!env.COLORTERM || term.includes("256color"),
112
+ color256: true,
113
+ name: "tmux",
114
+ };
115
+ }
116
+ // GNU screen — limited mouse support, no SGR
117
+ if (term === "screen" && !env.TMUX) {
118
+ return {
119
+ isTTY: true,
120
+ alternateScreen: true,
121
+ bracketedPaste: false,
122
+ mouse: true,
123
+ sgrMouse: false,
124
+ truecolor: false,
125
+ color256: false,
126
+ name: "screen",
127
+ };
128
+ }
129
+ // iTerm2
130
+ if (termProgram === "iTerm.app" || env.ITERM_SESSION_ID) {
131
+ return {
132
+ isTTY: true,
133
+ alternateScreen: true,
134
+ bracketedPaste: true,
135
+ mouse: true,
136
+ sgrMouse: true,
137
+ truecolor: true,
138
+ color256: true,
139
+ name: "iterm2",
140
+ };
141
+ }
142
+ // VS Code integrated terminal (macOS/Linux)
143
+ if (termProgram === "vscode") {
144
+ return {
145
+ isTTY: true,
146
+ alternateScreen: true,
147
+ bracketedPaste: true,
148
+ mouse: true,
149
+ sgrMouse: true,
150
+ truecolor: true,
151
+ color256: true,
152
+ name: "vscode",
153
+ };
154
+ }
155
+ // Alacritty
156
+ if (termProgram === "Alacritty" || term === "alacritty") {
157
+ return {
158
+ isTTY: true,
159
+ alternateScreen: true,
160
+ bracketedPaste: true,
161
+ mouse: true,
162
+ sgrMouse: true,
163
+ truecolor: true,
164
+ color256: true,
165
+ name: "alacritty",
166
+ };
167
+ }
168
+ // xterm-compatible (most Linux/macOS terminals)
169
+ if (term.startsWith("xterm") || term.includes("256color")) {
170
+ const hasTruecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
171
+ return {
172
+ isTTY: true,
173
+ alternateScreen: true,
174
+ bracketedPaste: true,
175
+ mouse: true,
176
+ sgrMouse: true,
177
+ truecolor: hasTruecolor,
178
+ color256: true,
179
+ name: term,
180
+ };
181
+ }
182
+ // Dumb terminal — absolute minimum
183
+ if (term === "dumb" || !term) {
184
+ return {
185
+ isTTY: true,
186
+ alternateScreen: false,
187
+ bracketedPaste: false,
188
+ mouse: false,
189
+ sgrMouse: false,
190
+ truecolor: false,
191
+ color256: false,
192
+ name: term || "unknown",
193
+ };
194
+ }
195
+ // Unknown but it is a TTY — enable common capabilities
196
+ return {
197
+ isTTY: true,
198
+ alternateScreen: true,
199
+ bracketedPaste: true,
200
+ mouse: true,
201
+ sgrMouse: false,
202
+ truecolor: false,
203
+ color256: true,
204
+ name: term,
205
+ };
206
+ }
package/dist/app.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * RenderTarget, DrawingContext, InputProcessor) and drives the
6
6
  * measure → arrange → render loop in response to input and resize events.
7
7
  */
8
+ import { type TerminalCaps } from "./ansi/terminal-env.js";
8
9
  import type { Control } from "./layout/control.js";
9
10
  export interface AppOptions {
10
11
  /** Root control to render. */
@@ -21,6 +22,7 @@ export declare class App {
21
22
  private readonly _alternateScreen;
22
23
  private readonly _mouse;
23
24
  private readonly _title;
25
+ private readonly _caps;
24
26
  private _output;
25
27
  private _buffer;
26
28
  private _dirtyRegions;
@@ -34,6 +36,8 @@ export declare class App {
34
36
  private _resizeListener;
35
37
  private _sigintListener;
36
38
  private _renderScheduled;
39
+ /** Detected terminal capabilities (read-only, for diagnostics). */
40
+ get caps(): Readonly<TerminalCaps>;
37
41
  constructor(options: AppOptions);
38
42
  /**
39
43
  * Start the app — enters raw mode, sets up terminal, runs the event
package/dist/app.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as esc from "./ansi/esc.js";
9
9
  import { AnsiOutput } from "./ansi/output.js";
10
+ import { detectTerminal, } from "./ansi/terminal-env.js";
10
11
  import { DrawingContext } from "./drawing/context.js";
11
12
  import { createInputProcessor } from "./input/processor.js";
12
13
  import { disableRawMode, enableRawMode } from "./input/raw-mode.js";
@@ -19,6 +20,7 @@ export class App {
19
20
  _alternateScreen;
20
21
  _mouse;
21
22
  _title;
23
+ _caps;
22
24
  // Subsystems — created during run()
23
25
  _output;
24
26
  _buffer;
@@ -34,11 +36,16 @@ export class App {
34
36
  _resizeListener = null;
35
37
  _sigintListener = null;
36
38
  _renderScheduled = false;
39
+ /** Detected terminal capabilities (read-only, for diagnostics). */
40
+ get caps() {
41
+ return this._caps;
42
+ }
37
43
  constructor(options) {
38
44
  this.root = options.root;
39
45
  this._alternateScreen = options.alternateScreen ?? true;
40
46
  this._mouse = options.mouse ?? false;
41
47
  this._title = options.title;
48
+ this._caps = detectTerminal();
42
49
  }
43
50
  // ── Public API ───────────────────────────────────────────────────
44
51
  /**
@@ -114,30 +121,21 @@ export class App {
114
121
  }
115
122
  _prepareTerminal() {
116
123
  const stream = process.stdout;
117
- let seq = "";
118
- if (this._alternateScreen) {
119
- seq += esc.alternateScreenOn;
120
- }
121
- seq += esc.hideCursor;
122
- seq += esc.bracketedPasteOn;
123
- if (this._mouse) {
124
- seq += esc.mouseTrackingOn;
125
- }
126
- seq += esc.clearScreen;
127
- stream.write(seq);
124
+ const seq = esc.initSequence(this._caps, {
125
+ alternateScreen: this._alternateScreen,
126
+ mouse: this._mouse,
127
+ });
128
+ if (seq)
129
+ stream.write(seq);
128
130
  }
129
131
  _restoreTerminal() {
130
132
  const stream = process.stdout;
131
- let seq = esc.reset;
132
- if (this._mouse) {
133
- seq += esc.mouseTrackingOff;
134
- }
135
- seq += esc.bracketedPasteOff;
136
- seq += esc.showCursor;
137
- if (this._alternateScreen) {
138
- seq += esc.alternateScreenOff;
139
- }
140
- stream.write(seq);
133
+ const seq = esc.restoreSequence(this._caps, {
134
+ alternateScreen: this._alternateScreen,
135
+ mouse: this._mouse,
136
+ });
137
+ if (seq)
138
+ stream.write(seq);
141
139
  }
142
140
  _createRenderPipeline(cols, rows) {
143
141
  this._buffer = new PixelBuffer(cols, rows);
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export type { Constraint, Point, Rect, Size, } from "./layout/types.js";
16
16
  export * as esc from "./ansi/esc.js";
17
17
  export { AnsiOutput } from "./ansi/output.js";
18
18
  export { stripAnsi, truncateAnsi, visibleLength, } from "./ansi/strip.js";
19
+ export { type TerminalCaps, detectTerminal, } from "./ansi/terminal-env.js";
19
20
  export { DirtyRegions, DirtySnapshot } from "./render/regions.js";
20
21
  export { RenderTarget } from "./render/render-target.js";
21
22
  export { EscapeMatcher } from "./input/escape-matcher.js";
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { charWidth, EMPTY_SYMBOL, isZeroWidth, stringDisplayWidth, sym, } from "
23
23
  export * as esc from "./ansi/esc.js";
24
24
  export { AnsiOutput } from "./ansi/output.js";
25
25
  export { stripAnsi, truncateAnsi, visibleLength, } from "./ansi/strip.js";
26
+ export { detectTerminal, } from "./ansi/terminal-env.js";
26
27
  // ── Render pipeline ─────────────────────────────────────────────────
27
28
  export { DirtyRegions, DirtySnapshot } from "./render/regions.js";
28
29
  export { RenderTarget } from "./render/render-target.js";
@@ -1,8 +1,14 @@
1
1
  /**
2
- * Parses SGR extended mouse tracking sequences.
2
+ * Parses terminal mouse tracking sequences.
3
3
  *
4
- * Format: \x1b[<Cb;Cx;CyM (press/motion)
5
- * \x1b[<Cb;Cx;Cym (release)
4
+ * Supported formats:
5
+ * SGR: \x1b[<Cb;Cx;CyM (press/motion)
6
+ * \x1b[<Cb;Cx;Cym (release)
7
+ * SGR-Pixels: \x1b[<Cb;Cx;CyM (same wire format as SGR, pixel coords)
8
+ * \x1b[<Cb;Cx;Cym
9
+ * X10: \x1b[M Cb Cx Cy (classic xterm byte encoding)
10
+ * UTF-8: \x1b[M Cb Cx Cy (same prefix as X10, UTF-8 encoded coords)
11
+ * URXVT: \x1b[Cb;Cx;CyM (decimal params, no < prefix)
6
12
  *
7
13
  * Cb encodes button and modifiers:
8
14
  * bits 0-1: 0=left, 1=middle, 2=right
@@ -13,15 +19,27 @@
13
19
  * bit 4 (+16): ctrl
14
20
  *
15
21
  * Cx, Cy are 1-based coordinates.
22
+ *
23
+ * Note: UTF-8 mode uses the same \x1b[M prefix as X10 but encodes
24
+ * coordinates as UTF-8 characters for values > 127. Node.js decodes
25
+ * UTF-8 stdin automatically, so the X10 parser handles both formats.
26
+ *
27
+ * Note: SGR-Pixels mode uses the same wire format as SGR but reports
28
+ * pixel coordinates instead of cell coordinates. These are passed
29
+ * through as-is (the caller must convert to cells if needed).
16
30
  */
17
31
  import { type InputEvent } from "./events.js";
18
32
  import { type IMatcher, MatchResult } from "./matcher.js";
19
33
  export declare class MouseMatcher implements IMatcher {
20
34
  private state;
21
35
  private params;
36
+ private x10Bytes;
37
+ private urxvtParams;
22
38
  private result;
23
39
  append(char: string): MatchResult;
24
40
  flush(): InputEvent | null;
25
41
  reset(): void;
26
42
  private finalize;
43
+ private finalizeUrxvt;
44
+ private finalizeX10;
27
45
  }
@@ -1,8 +1,14 @@
1
1
  /**
2
- * Parses SGR extended mouse tracking sequences.
2
+ * Parses terminal mouse tracking sequences.
3
3
  *
4
- * Format: \x1b[<Cb;Cx;CyM (press/motion)
5
- * \x1b[<Cb;Cx;Cym (release)
4
+ * Supported formats:
5
+ * SGR: \x1b[<Cb;Cx;CyM (press/motion)
6
+ * \x1b[<Cb;Cx;Cym (release)
7
+ * SGR-Pixels: \x1b[<Cb;Cx;CyM (same wire format as SGR, pixel coords)
8
+ * \x1b[<Cb;Cx;Cym
9
+ * X10: \x1b[M Cb Cx Cy (classic xterm byte encoding)
10
+ * UTF-8: \x1b[M Cb Cx Cy (same prefix as X10, UTF-8 encoded coords)
11
+ * URXVT: \x1b[Cb;Cx;CyM (decimal params, no < prefix)
6
12
  *
7
13
  * Cb encodes button and modifiers:
8
14
  * bits 0-1: 0=left, 1=middle, 2=right
@@ -13,6 +19,14 @@
13
19
  * bit 4 (+16): ctrl
14
20
  *
15
21
  * Cx, Cy are 1-based coordinates.
22
+ *
23
+ * Note: UTF-8 mode uses the same \x1b[M prefix as X10 but encodes
24
+ * coordinates as UTF-8 characters for values > 127. Node.js decodes
25
+ * UTF-8 stdin automatically, so the X10 parser handles both formats.
26
+ *
27
+ * Note: SGR-Pixels mode uses the same wire format as SGR but reports
28
+ * pixel coordinates instead of cell coordinates. These are passed
29
+ * through as-is (the caller must convert to cells if needed).
16
30
  */
17
31
  import { mouseEvent } from "./events.js";
18
32
  import { MatchResult } from "./matcher.js";
@@ -24,12 +38,18 @@ var State;
24
38
  State[State["GotEsc"] = 1] = "GotEsc";
25
39
  /** Got \x1b[ */
26
40
  State[State["GotBracket"] = 2] = "GotBracket";
27
- /** Got \x1b[< — now reading params until M or m */
41
+ /** Got \x1b[< — now reading SGR/SGR-Pixels params until M or m */
28
42
  State[State["Reading"] = 3] = "Reading";
43
+ /** Got \x1b[M — now reading three encoded bytes (X10 or UTF-8) */
44
+ State[State["ReadingX10"] = 4] = "ReadingX10";
45
+ /** Got \x1b[ followed by a digit — reading URXVT decimal params until M */
46
+ State[State["ReadingUrxvt"] = 5] = "ReadingUrxvt";
29
47
  })(State || (State = {}));
30
48
  export class MouseMatcher {
31
49
  state = State.Idle;
32
50
  params = "";
51
+ x10Bytes = [];
52
+ urxvtParams = "";
33
53
  result = null;
34
54
  append(char) {
35
55
  switch (this.state) {
@@ -52,6 +72,17 @@ export class MouseMatcher {
52
72
  this.params = "";
53
73
  return MatchResult.Partial;
54
74
  }
75
+ if (char === "M") {
76
+ this.state = State.ReadingX10;
77
+ this.x10Bytes = [];
78
+ return MatchResult.Partial;
79
+ }
80
+ // URXVT: \x1b[ followed by a digit starts decimal param reading
81
+ if (char >= "0" && char <= "9") {
82
+ this.state = State.ReadingUrxvt;
83
+ this.urxvtParams = char;
84
+ return MatchResult.Partial;
85
+ }
55
86
  this.state = State.Idle;
56
87
  return MatchResult.NoMatch;
57
88
  case State.Reading: {
@@ -69,6 +100,26 @@ export class MouseMatcher {
69
100
  this.params = "";
70
101
  return MatchResult.NoMatch;
71
102
  }
103
+ case State.ReadingX10:
104
+ this.x10Bytes.push(char);
105
+ if (this.x10Bytes.length < 3) {
106
+ return MatchResult.Partial;
107
+ }
108
+ return this.finalizeX10();
109
+ case State.ReadingUrxvt: {
110
+ if (char === "M") {
111
+ return this.finalizeUrxvt();
112
+ }
113
+ const c = char.charCodeAt(0);
114
+ if ((c >= 0x30 && c <= 0x39) || char === ";") {
115
+ this.urxvtParams += char;
116
+ return MatchResult.Partial;
117
+ }
118
+ // Not a valid URXVT sequence — bail out
119
+ this.state = State.Idle;
120
+ this.urxvtParams = "";
121
+ return MatchResult.NoMatch;
122
+ }
72
123
  default:
73
124
  return MatchResult.NoMatch;
74
125
  }
@@ -81,6 +132,8 @@ export class MouseMatcher {
81
132
  reset() {
82
133
  this.state = State.Idle;
83
134
  this.params = "";
135
+ this.x10Bytes = [];
136
+ this.urxvtParams = "";
84
137
  this.result = null;
85
138
  }
86
139
  finalize(isRelease) {
@@ -96,37 +149,77 @@ export class MouseMatcher {
96
149
  if (Number.isNaN(cb) || Number.isNaN(cx) || Number.isNaN(cy)) {
97
150
  return MatchResult.NoMatch;
98
151
  }
99
- // Decode modifiers from cb
100
- const shift = (cb & 4) !== 0;
101
- const alt = (cb & 8) !== 0;
102
- const ctrl = (cb & 16) !== 0;
103
- const isMotion = (cb & 32) !== 0;
104
- // Decode button from low bits (masking out modifier/motion bits)
105
- const buttonBits = cb & 3;
106
- const highBits = cb & (64 | 128);
107
- let button;
108
- let type;
109
- if (highBits === 64) {
110
- // Wheel events
111
- button = "none";
112
- type = buttonBits === 0 ? "wheelup" : "wheeldown";
152
+ if (isRelease) {
153
+ const shift = (cb & 4) !== 0;
154
+ const alt = (cb & 8) !== 0;
155
+ const ctrl = (cb & 16) !== 0;
156
+ this.result = mouseEvent(cx - 1, cy - 1, decodeButton(cb & 3), "release", shift, ctrl, alt);
157
+ return MatchResult.Complete;
113
158
  }
114
- else if (isRelease) {
115
- button = decodeButton(buttonBits);
116
- type = "release";
159
+ this.result = decodeMouseEvent(cb, cx, cy, true);
160
+ return this.result ? MatchResult.Complete : MatchResult.NoMatch;
161
+ }
162
+ finalizeUrxvt() {
163
+ this.state = State.Idle;
164
+ const parts = this.urxvtParams.split(";");
165
+ this.urxvtParams = "";
166
+ if (parts.length !== 3) {
167
+ return MatchResult.NoMatch;
168
+ }
169
+ const cb = parseInt(parts[0], 10);
170
+ const cx = parseInt(parts[1], 10);
171
+ const cy = parseInt(parts[2], 10);
172
+ if (Number.isNaN(cb) || Number.isNaN(cx) || Number.isNaN(cy)) {
173
+ return MatchResult.NoMatch;
117
174
  }
118
- else if (isMotion) {
119
- button = buttonBits === 3 ? "none" : decodeButton(buttonBits);
120
- type = "move";
175
+ // URXVT uses the same button encoding as X10 (button 3 = release)
176
+ this.result = decodeMouseEvent(cb, cx, cy, true);
177
+ return this.result ? MatchResult.Complete : MatchResult.NoMatch;
178
+ }
179
+ finalizeX10() {
180
+ this.state = State.Idle;
181
+ if (this.x10Bytes.length !== 3) {
182
+ this.x10Bytes = [];
183
+ return MatchResult.NoMatch;
121
184
  }
122
- else {
123
- button = decodeButton(buttonBits);
124
- type = "press";
185
+ const [cbChar, cxChar, cyChar] = this.x10Bytes;
186
+ this.x10Bytes = [];
187
+ const cb = cbChar.charCodeAt(0) - 32;
188
+ const cx = cxChar.charCodeAt(0) - 32;
189
+ const cy = cyChar.charCodeAt(0) - 32;
190
+ if (cb < 0 || cx <= 0 || cy <= 0) {
191
+ return MatchResult.NoMatch;
125
192
  }
126
- // Convert from 1-based to 0-based coordinates
127
- this.result = mouseEvent(cx - 1, cy - 1, button, type, shift, ctrl, alt);
128
- return MatchResult.Complete;
193
+ this.result = decodeMouseEvent(cb, cx, cy, true);
194
+ return this.result ? MatchResult.Complete : MatchResult.NoMatch;
195
+ }
196
+ }
197
+ function decodeMouseEvent(cb, cx, cy, x10ReleaseUsesButton3) {
198
+ const shift = (cb & 4) !== 0;
199
+ const alt = (cb & 8) !== 0;
200
+ const ctrl = (cb & 16) !== 0;
201
+ const isMotion = (cb & 32) !== 0;
202
+ const buttonBits = cb & 3;
203
+ const highBits = cb & (64 | 128);
204
+ let button;
205
+ let type;
206
+ if (highBits === 64) {
207
+ button = "none";
208
+ type = buttonBits === 0 ? "wheelup" : "wheeldown";
209
+ }
210
+ else if (x10ReleaseUsesButton3 && !isMotion && buttonBits === 3) {
211
+ button = "none";
212
+ type = "release";
213
+ }
214
+ else if (isMotion) {
215
+ button = buttonBits === 3 ? "none" : decodeButton(buttonBits);
216
+ type = "move";
217
+ }
218
+ else {
219
+ button = decodeButton(buttonBits);
220
+ type = "press";
129
221
  }
222
+ return mouseEvent(cx - 1, cy - 1, button, type, shift, ctrl, alt);
130
223
  }
131
224
  function decodeButton(bits) {
132
225
  switch (bits) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/consolonia",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Terminal UI rendering engine inspired by Consolonia. Pixel-level compositing with ANSI output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",