@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 +1 -1
- package/src/build-leader-chords.ts +27 -0
- package/src/index.ts +17 -9
- package/src/reset-input-modes.ts +11 -0
- package/src/screen-model.ts +75 -1
- package/src/ui.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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';
|
package/src/screen-model.ts
CHANGED
|
@@ -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`,
|