@zgeoff/atc 0.1.7 → 0.1.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Every byte sequence a terminal may deliver for the leader key. A terminal
3
+ * with no keyboard enhancements sends the bare control byte, one running the
4
+ * kitty keyboard protocol sends a CSI-u chord, and one running xterm's
5
+ * modifyOtherKeys sends a CSI-27 chord — a fullscreen agent can switch the
6
+ * terminal into either of the enhanced encodings mid-session.
7
+ */
8
+ export function buildLeaderChords(code: number): readonly string[] {
9
+ const base = getBaseCodePoint(code);
10
+
11
+ return [String.fromCodePoint(code), `\u001B[${base};5u`, `\u001B[27;5;${base}~`];
12
+ }
13
+
14
+ // The enhanced encodings name the unshifted key, not the control byte it
15
+ // maps to: ctrl-space carries space, ctrl-a through ctrl-z carry the
16
+ // lowercase letter, and the punctuation controls carry their symbol.
17
+ function getBaseCodePoint(code: number): number {
18
+ if (code === 0) {
19
+ return 32;
20
+ }
21
+
22
+ if (code <= 26) {
23
+ return code + 96;
24
+ }
25
+
26
+ return code + 64;
27
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename } from 'node:path';
2
2
  import { bootDaemonClient } from './boot-daemon';
3
+ import { buildLeaderChords } from './build-leader-chords';
3
4
  import { loadConfig } from './config';
4
5
  import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
5
6
  import { pickTabTarget } from './pick-tab-target';
@@ -193,7 +194,7 @@ function openHelp() {
193
194
  function applyHelpKey(buf: Buffer) {
194
195
  const ch = buf.toString();
195
196
 
196
- if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === leader.code) {
197
+ if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || isLeaderKey(buf)) {
197
198
  openOverlay();
198
199
  }
199
200
  }
@@ -240,7 +241,7 @@ function openOverlay() {
240
241
 
241
242
  overlaySelected = Math.max(0, focusedIndex);
242
243
 
243
- stdout.write(ansi.clear);
244
+ stdout.write(ansi.resetInputModes + ansi.clear);
244
245
 
245
246
  renderOverlay();
246
247
  }
@@ -375,7 +376,7 @@ function quit(code = 0): never {
375
376
  detachFocused();
376
377
 
377
378
  client.stop();
378
- stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
379
+ stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
379
380
 
380
381
  try {
381
382
  process.stdin.setRawMode(false);
@@ -572,6 +573,7 @@ function copyToClipboard(text: string) {
572
573
  // ---- input ----
573
574
 
574
575
  const leader = loadConfig().leader;
576
+ const leaderChords = buildLeaderChords(leader.code);
575
577
 
576
578
  const KEY = {
577
579
  tab: 0x09,
@@ -590,6 +592,12 @@ function isDown(buf: Buffer): boolean {
590
592
  return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
591
593
  }
592
594
 
595
+ // A fullscreen session can leave the terminal in an enhanced keyboard
596
+ // encoding, so the leader arrives as a CSI chord instead of its bare byte.
597
+ function isLeaderKey(buf: Buffer): boolean {
598
+ return leaderChords.includes(buf.toString());
599
+ }
600
+
593
601
  function applyOverlayFilterKey(buf: Buffer): boolean {
594
602
  if (overlayFilter === null) {
595
603
  return false;
@@ -627,7 +635,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
627
635
  return true;
628
636
  }
629
637
 
630
- if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === leader.code) {
638
+ if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || isLeaderKey(buf)) {
631
639
  return false;
632
640
  }
633
641
 
@@ -685,7 +693,7 @@ function applyOverlayKey(buf: Buffer) {
685
693
  return;
686
694
  }
687
695
 
688
- if (buf[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
696
+ if (isLeaderKey(buf) || (buf[0] === KEY.esc && buf.length === 1)) {
689
697
  toBase();
690
698
 
691
699
  return;
@@ -843,7 +851,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
843
851
  return;
844
852
  }
845
853
 
846
- if (buf[0] === leader.code) {
854
+ if (isLeaderKey(buf)) {
847
855
  toBase();
848
856
 
849
857
  return;
@@ -985,7 +993,7 @@ const stdinDecoder = new TextDecoder('utf-8');
985
993
  process.stdin.on('data', (buf: Buffer) => {
986
994
  switch (mode) {
987
995
  case 'attached': {
988
- if (buf[0] === leader.code && buf.length === 1) {
996
+ if (isLeaderKey(buf)) {
989
997
  openOverlay();
990
998
 
991
999
  return;
@@ -1001,7 +1009,7 @@ process.stdin.on('data', (buf: Buffer) => {
1001
1009
  return;
1002
1010
  }
1003
1011
  case 'home': {
1004
- if (buf[0] === leader.code) {
1012
+ if (isLeaderKey(buf)) {
1005
1013
  openOverlay();
1006
1014
 
1007
1015
  return;
@@ -1188,7 +1196,7 @@ stdout.on('resize', () => {
1188
1196
  });
1189
1197
 
1190
1198
  process.on('uncaughtException', (err) => {
1191
- stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
1199
+ stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
1192
1200
 
1193
1201
  try {
1194
1202
  process.stdin.setRawMode(false);
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Returns the terminal's input handling to its unenhanced defaults: pops the
3
+ * kitty keyboard protocol, clears modifyOtherKeys, and turns off every mode
4
+ * that changes key encoding or injects reports into stdin (mouse tracking,
5
+ * SGR mouse encoding, alternate scroll, focus events, bracketed paste, and
6
+ * color-scheme reports). Written when a session's screen is replaced by a
7
+ * chrome screen or the outer shell, so a session's input modes never leak
8
+ * past its own screen; a replay re-applies whatever the session had set.
9
+ */
10
+ export const RESET_INPUT_MODES =
11
+ '\u001B[<u\u001B[>4;0m\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1006l\u001B[?1007l\u001B[?1004l\u001B[?2004l\u001B[?2031l';
@@ -1,5 +1,10 @@
1
1
  import { SerializeAddon } from '@xterm/addon-serialize';
2
2
  import { Terminal } from '@xterm/headless';
3
+ import { RESET_INPUT_MODES } from './reset-input-modes';
4
+
5
+ // The serializer already re-emits the modes the vt engine models (mouse
6
+ // tracking, bracketed paste, focus events); these are the ones it drops.
7
+ const REPLAYED_DEC_MODES = new Set([1006, 1007, 2031]);
3
8
 
4
9
  /**
5
10
  * A per-session vt state machine: consumes every PTY byte continuously and
@@ -7,6 +12,13 @@ import { Terminal } from '@xterm/headless';
7
12
  * client is an instant repaint instead of a resize jiggle. Scrollback is
8
13
  * capped aggressively — the model exists for the current screen, not
9
14
  * history; transcripts on disk are the durable copy.
15
+ *
16
+ * The serializer covers buffer content and the modes the vt engine tracks,
17
+ * but not the input-encoding state a fullscreen agent sets: SGR mouse
18
+ * encoding, alternate scroll, color-scheme reports, the kitty keyboard
19
+ * protocol, and modifyOtherKeys. Those are tracked here and folded into the
20
+ * replay, which first resets every input mode so a replay painted over
21
+ * another session's screen never inherits its input state.
10
22
  */
11
23
  export class ScreenModel {
12
24
  private readonly term: Terminal;
@@ -15,11 +27,53 @@ export class ScreenModel {
15
27
 
16
28
  private flushed: Promise<void> = Promise.resolve();
17
29
 
30
+ private readonly decModes = new Map<number, boolean>();
31
+
32
+ private readonly kittyFlags: number[] = [];
33
+
34
+ private modifyOtherKeys = 0;
35
+
18
36
  constructor(cols: number, rows: number) {
19
37
  this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 300 });
20
38
  this.serializer = new SerializeAddon();
21
39
 
22
40
  this.term.loadAddon(this.serializer);
41
+
42
+ for (const final of ['h', 'l'] as const) {
43
+ this.term.parser.registerCsiHandler({ prefix: '?', final }, (params) => {
44
+ for (const p of params) {
45
+ if (typeof p === 'number' && REPLAYED_DEC_MODES.has(p)) {
46
+ this.decModes.set(p, final === 'h');
47
+ }
48
+ }
49
+
50
+ return false;
51
+ });
52
+ }
53
+
54
+ this.term.parser.registerCsiHandler({ prefix: '>', final: 'u' }, (params) => {
55
+ const flags = typeof params[0] === 'number' ? params[0] : 0;
56
+
57
+ this.kittyFlags.push(flags);
58
+
59
+ return false;
60
+ });
61
+
62
+ this.term.parser.registerCsiHandler({ prefix: '<', final: 'u' }, (params) => {
63
+ const count = typeof params[0] === 'number' && params[0] > 0 ? params[0] : 1;
64
+
65
+ this.kittyFlags.splice(Math.max(0, this.kittyFlags.length - count));
66
+
67
+ return false;
68
+ });
69
+
70
+ this.term.parser.registerCsiHandler({ prefix: '>', final: 'm' }, (params) => {
71
+ if (params[0] === 4) {
72
+ this.modifyOtherKeys = typeof params[1] === 'number' ? params[1] : 0;
73
+ }
74
+
75
+ return false;
76
+ });
23
77
  }
24
78
 
25
79
  record(data: string): void {
@@ -35,7 +89,7 @@ export class ScreenModel {
35
89
  async renderReplay(): Promise<string> {
36
90
  await this.flushed;
37
91
 
38
- return this.serializer.serialize();
92
+ return RESET_INPUT_MODES + this.serializer.serialize() + this.renderInputModes();
39
93
  }
40
94
 
41
95
  updateDims(cols: number, rows: number): void {
@@ -45,4 +99,24 @@ export class ScreenModel {
45
99
  stop(): void {
46
100
  this.term.dispose();
47
101
  }
102
+
103
+ private renderInputModes(): string {
104
+ let out = '';
105
+
106
+ for (const [mode, on] of this.decModes) {
107
+ if (on) {
108
+ out += `\u001B[?${mode}h`;
109
+ }
110
+ }
111
+
112
+ for (const flags of this.kittyFlags) {
113
+ out += `\u001B[>${flags}u`;
114
+ }
115
+
116
+ if (this.modifyOtherKeys > 0) {
117
+ out += `\u001B[>4;${this.modifyOtherKeys}m`;
118
+ }
119
+
120
+ return out;
121
+ }
48
122
  }
package/src/ui.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { formatDir } from './dirs';
2
+ import { RESET_INPUT_MODES } from './reset-input-modes';
2
3
  import type { SessionState } from './sessions';
3
4
 
4
5
  // The slice of a session the drawing layer needs; satisfied by both the
@@ -15,6 +16,7 @@ const ESC = '\u001B';
15
16
 
16
17
  export const ansi = {
17
18
  altScreenOn: `${ESC}[?1049h`,
19
+ resetInputModes: RESET_INPUT_MODES,
18
20
  altScreenOff: `${ESC}[?1049l`,
19
21
  clear: `${ESC}[2J${ESC}[H`,
20
22
  hideCursor: `${ESC}[?25l`,