@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.6",
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,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[0] === leader.code) {
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[0] === leader.code) {
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[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
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 needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
721
+ const target = pickTabTarget(fleet, (id) => doneAt.get(id));
699
722
 
700
- if (needy !== undefined) {
701
- void attach(needy.id);
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[0] === leader.code) {
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[0] === leader.code && buf.length === 1) {
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[0] === leader.code) {
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';
@@ -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 session',
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',