@zgeoff/atc 0.1.6 → 0.1.7

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.7",
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",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
2
2
  import { bootDaemonClient } from './boot-daemon';
3
3
  import { loadConfig } from './config';
4
4
  import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
5
+ import { pickTabTarget } from './pick-tab-target';
5
6
  import type { EventMsg } from './protocol';
6
7
  import { isRecord } from './report';
7
8
  import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from './sessions';
@@ -39,6 +40,12 @@ let confirmKill = false;
39
40
 
40
41
  // The client's mirror of the daemon's fleet, kept fresh by events.
41
42
  let fleet: MirrorSession[] = [];
43
+
44
+ // When each session last flipped to done, so the Tab jump can prefer the
45
+ // most recently finished turn. Sessions already done before this client
46
+ // connected have no entry and rank last.
47
+ const doneAt = new Map<string, number>();
48
+
42
49
  let focusedID: string | null = null;
43
50
  let fleetCount = 0;
44
51
 
@@ -471,6 +478,10 @@ function applyDaemonEvent(e: EventMsg) {
471
478
  if (s !== undefined) {
472
479
  const next = SESSION_STATES.find((x) => x === e['state']);
473
480
 
481
+ if (next === 'done' && s.state !== 'done') {
482
+ doneAt.set(s.id, Date.now());
483
+ }
484
+
474
485
  if (next !== undefined) {
475
486
  s.state = next;
476
487
  }
@@ -524,6 +535,10 @@ function applyDaemonEvent(e: EventMsg) {
524
535
  case 'session.removed': {
525
536
  fleet = fleet.filter((x) => x.id !== e['s']);
526
537
 
538
+ if (typeof e['s'] === 'string') {
539
+ doneAt.delete(e['s']);
540
+ }
541
+
527
542
  if (focusedID === e['s']) {
528
543
  focusedID = null;
529
544
  }
@@ -695,10 +710,10 @@ function applyOverlayKey(buf: Buffer) {
695
710
  const ch = buf.toString();
696
711
 
697
712
  if (buf[0] === KEY.tab) {
698
- const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
713
+ const target = pickTabTarget(fleet, (id) => doneAt.get(id));
699
714
 
700
- if (needy !== undefined) {
701
- void attach(needy.id);
715
+ if (target !== undefined) {
716
+ void attach(target.id);
702
717
  }
703
718
 
704
719
  return;
@@ -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
+ }
package/src/ui.ts CHANGED
@@ -274,7 +274,7 @@ export function drawHelp() {
274
274
 
275
275
  const lines = [
276
276
  '⏎ attach the selected session',
277
- '⇥ attach the most urgent needs-you session',
277
+ '⇥ attach the most urgent needs-you, else latest done',
278
278
  'a ack its notification without attaching',
279
279
  'H eject to a headless run',
280
280
  'P revive a dead or headless session',