@zgeoff/atc 0.1.6 → 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/README.md +1 -1
- package/package.json +1 -1
- package/src/build-leader-chords.ts +27 -0
- package/src/index.ts +35 -12
- package/src/pick-tab-target.ts +31 -0
- package/src/reset-input-modes.ts +11 -0
- package/src/screen-model.ts +75 -1
- package/src/ui.ts +3 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
43
43
|
| `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
|
|
44
44
|
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
45
45
|
| `Enter` | overlay | attach (auto-acks) |
|
|
46
|
-
| `Tab` | overlay | attach the most urgent needs-you session
|
|
46
|
+
| `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
|
|
47
47
|
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
|
|
48
48
|
| `a` | overlay | ack notification without attaching |
|
|
49
49
|
| `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
|
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,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { pickTabTarget } from './pick-tab-target';
|
|
5
7
|
import type { EventMsg } from './protocol';
|
|
6
8
|
import { isRecord } from './report';
|
|
7
9
|
import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from './sessions';
|
|
@@ -39,6 +41,12 @@ let confirmKill = false;
|
|
|
39
41
|
|
|
40
42
|
// The client's mirror of the daemon's fleet, kept fresh by events.
|
|
41
43
|
let fleet: MirrorSession[] = [];
|
|
44
|
+
|
|
45
|
+
// When each session last flipped to done, so the Tab jump can prefer the
|
|
46
|
+
// most recently finished turn. Sessions already done before this client
|
|
47
|
+
// connected have no entry and rank last.
|
|
48
|
+
const doneAt = new Map<string, number>();
|
|
49
|
+
|
|
42
50
|
let focusedID: string | null = null;
|
|
43
51
|
let fleetCount = 0;
|
|
44
52
|
|
|
@@ -186,7 +194,7 @@ function openHelp() {
|
|
|
186
194
|
function applyHelpKey(buf: Buffer) {
|
|
187
195
|
const ch = buf.toString();
|
|
188
196
|
|
|
189
|
-
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf
|
|
197
|
+
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || isLeaderKey(buf)) {
|
|
190
198
|
openOverlay();
|
|
191
199
|
}
|
|
192
200
|
}
|
|
@@ -233,7 +241,7 @@ function openOverlay() {
|
|
|
233
241
|
|
|
234
242
|
overlaySelected = Math.max(0, focusedIndex);
|
|
235
243
|
|
|
236
|
-
stdout.write(ansi.clear);
|
|
244
|
+
stdout.write(ansi.resetInputModes + ansi.clear);
|
|
237
245
|
|
|
238
246
|
renderOverlay();
|
|
239
247
|
}
|
|
@@ -368,7 +376,7 @@ function quit(code = 0): never {
|
|
|
368
376
|
detachFocused();
|
|
369
377
|
|
|
370
378
|
client.stop();
|
|
371
|
-
stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
|
|
379
|
+
stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
|
|
372
380
|
|
|
373
381
|
try {
|
|
374
382
|
process.stdin.setRawMode(false);
|
|
@@ -471,6 +479,10 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
471
479
|
if (s !== undefined) {
|
|
472
480
|
const next = SESSION_STATES.find((x) => x === e['state']);
|
|
473
481
|
|
|
482
|
+
if (next === 'done' && s.state !== 'done') {
|
|
483
|
+
doneAt.set(s.id, Date.now());
|
|
484
|
+
}
|
|
485
|
+
|
|
474
486
|
if (next !== undefined) {
|
|
475
487
|
s.state = next;
|
|
476
488
|
}
|
|
@@ -524,6 +536,10 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
524
536
|
case 'session.removed': {
|
|
525
537
|
fleet = fleet.filter((x) => x.id !== e['s']);
|
|
526
538
|
|
|
539
|
+
if (typeof e['s'] === 'string') {
|
|
540
|
+
doneAt.delete(e['s']);
|
|
541
|
+
}
|
|
542
|
+
|
|
527
543
|
if (focusedID === e['s']) {
|
|
528
544
|
focusedID = null;
|
|
529
545
|
}
|
|
@@ -557,6 +573,7 @@ function copyToClipboard(text: string) {
|
|
|
557
573
|
// ---- input ----
|
|
558
574
|
|
|
559
575
|
const leader = loadConfig().leader;
|
|
576
|
+
const leaderChords = buildLeaderChords(leader.code);
|
|
560
577
|
|
|
561
578
|
const KEY = {
|
|
562
579
|
tab: 0x09,
|
|
@@ -575,6 +592,12 @@ function isDown(buf: Buffer): boolean {
|
|
|
575
592
|
return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
|
|
576
593
|
}
|
|
577
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
|
+
|
|
578
601
|
function applyOverlayFilterKey(buf: Buffer): boolean {
|
|
579
602
|
if (overlayFilter === null) {
|
|
580
603
|
return false;
|
|
@@ -612,7 +635,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
|
|
|
612
635
|
return true;
|
|
613
636
|
}
|
|
614
637
|
|
|
615
|
-
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf
|
|
638
|
+
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || isLeaderKey(buf)) {
|
|
616
639
|
return false;
|
|
617
640
|
}
|
|
618
641
|
|
|
@@ -670,7 +693,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
670
693
|
return;
|
|
671
694
|
}
|
|
672
695
|
|
|
673
|
-
if (buf
|
|
696
|
+
if (isLeaderKey(buf) || (buf[0] === KEY.esc && buf.length === 1)) {
|
|
674
697
|
toBase();
|
|
675
698
|
|
|
676
699
|
return;
|
|
@@ -695,10 +718,10 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
695
718
|
const ch = buf.toString();
|
|
696
719
|
|
|
697
720
|
if (buf[0] === KEY.tab) {
|
|
698
|
-
const
|
|
721
|
+
const target = pickTabTarget(fleet, (id) => doneAt.get(id));
|
|
699
722
|
|
|
700
|
-
if (
|
|
701
|
-
void attach(
|
|
723
|
+
if (target !== undefined) {
|
|
724
|
+
void attach(target.id);
|
|
702
725
|
}
|
|
703
726
|
|
|
704
727
|
return;
|
|
@@ -828,7 +851,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
828
851
|
return;
|
|
829
852
|
}
|
|
830
853
|
|
|
831
|
-
if (buf
|
|
854
|
+
if (isLeaderKey(buf)) {
|
|
832
855
|
toBase();
|
|
833
856
|
|
|
834
857
|
return;
|
|
@@ -970,7 +993,7 @@ const stdinDecoder = new TextDecoder('utf-8');
|
|
|
970
993
|
process.stdin.on('data', (buf: Buffer) => {
|
|
971
994
|
switch (mode) {
|
|
972
995
|
case 'attached': {
|
|
973
|
-
if (buf
|
|
996
|
+
if (isLeaderKey(buf)) {
|
|
974
997
|
openOverlay();
|
|
975
998
|
|
|
976
999
|
return;
|
|
@@ -986,7 +1009,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
986
1009
|
return;
|
|
987
1010
|
}
|
|
988
1011
|
case 'home': {
|
|
989
|
-
if (buf
|
|
1012
|
+
if (isLeaderKey(buf)) {
|
|
990
1013
|
openOverlay();
|
|
991
1014
|
|
|
992
1015
|
return;
|
|
@@ -1173,7 +1196,7 @@ stdout.on('resize', () => {
|
|
|
1173
1196
|
});
|
|
1174
1197
|
|
|
1175
1198
|
process.on('uncaughtException', (err) => {
|
|
1176
|
-
stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
|
|
1199
|
+
stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
|
|
1177
1200
|
|
|
1178
1201
|
try {
|
|
1179
1202
|
process.stdin.setRawMode(false);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionState } from './sessions';
|
|
2
|
+
import { sortSessionViews } from './sessions';
|
|
3
|
+
|
|
4
|
+
interface TabCandidate {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly state: SessionState;
|
|
7
|
+
readonly createdAt: number;
|
|
8
|
+
readonly alive: boolean;
|
|
9
|
+
readonly kind: 'pty' | 'jsonl';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Tab jump target: the most urgent alive needs-you session, else the
|
|
14
|
+
* alive terminal session whose turn finished most recently. Headless
|
|
15
|
+
* sessions never qualify for the fallback — they have no terminal to
|
|
16
|
+
* attach — and sessions with no recorded finish time rank last.
|
|
17
|
+
*/
|
|
18
|
+
export function pickTabTarget<T extends TabCandidate>(
|
|
19
|
+
list: readonly T[],
|
|
20
|
+
findDoneAt: (id: string) => number | undefined,
|
|
21
|
+
): T | undefined {
|
|
22
|
+
const needy = sortSessionViews(list).find((s) => s.state === 'needs_you' && s.alive);
|
|
23
|
+
|
|
24
|
+
if (needy !== undefined) {
|
|
25
|
+
return needy;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return sortSessionViews(list)
|
|
29
|
+
.filter((s) => s.state === 'done' && s.alive && s.kind === 'pty')
|
|
30
|
+
.toSorted((a, b) => (findDoneAt(b.id) ?? 0) - (findDoneAt(a.id) ?? 0))[0];
|
|
31
|
+
}
|
|
@@ -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`,
|
|
@@ -274,7 +276,7 @@ export function drawHelp() {
|
|
|
274
276
|
|
|
275
277
|
const lines = [
|
|
276
278
|
'⏎ attach the selected session',
|
|
277
|
-
'⇥ attach the most urgent needs-you
|
|
279
|
+
'⇥ attach the most urgent needs-you, else latest done',
|
|
278
280
|
'a ack its notification without attaching',
|
|
279
281
|
'H eject to a headless run',
|
|
280
282
|
'P revive a dead or headless session',
|