@zgeoff/atc 0.1.7 → 0.1.9

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
@@ -27,7 +27,10 @@ atc
27
27
  ```
28
28
 
29
29
  Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
30
- From a checkout, `bun src/cli.ts` runs the same thing.
30
+ From a checkout, `bun src/cli.ts` runs the same thing. atc is built to pair with
31
+ [zoxide](https://github.com/ajeetdsouza/zoxide): the spawn directory picker feeds on its frecency
32
+ list, so with zoxide installed every directory you visit is two keystrokes from a session. Without
33
+ it the picker falls back to atc's own spawn history.
31
34
 
32
35
  The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
33
36
  debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
@@ -38,7 +41,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
38
41
  | Key | Where | Action |
39
42
  | --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
40
43
  | leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
41
- | `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional group → optional first prompt |
44
+ | `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt |
42
45
  | `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
43
46
  | `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
44
47
  | `j`/`k`/`↑`/`↓` | overlay/picker | move |
@@ -46,6 +49,8 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
46
49
  | `Tab` | overlay | attach the most urgent needs-you session, else the latest turn-done one |
47
50
  | `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
48
51
  | `a` | overlay | ack notification without attaching |
52
+ | `p` | overlay | pin or unpin the selected session — pinned sessions stay at the top of the list |
53
+ | `g` | overlay | toggle the grouped view: sessions cluster under repository headers |
49
54
  | `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
50
55
  | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
51
56
  | `y` | overlay | yank `cd <dir> && claude --resume <id>` to clipboard (OSC 52 + clip.exe/wl-copy/xclip) |
@@ -55,10 +60,12 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
55
60
  | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
56
61
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
57
62
 
58
- The overlay clusters sessions under dim headers when more than one group exists: a session's
59
- explicit group wins, and sessions without one fall back to the directory they were spawned from.
60
- Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
61
- an agent can organise the fleet for you.
63
+ The overlay orders sessions by pinned first, then attention state, then most recently attached, so
64
+ the session you want is nearly always near the top. The grouped view (`g`) keeps that order but
65
+ clusters sessions under dim repository headers, with pinned sessions leading in their own cluster; a
66
+ git worktree clusters with its main repository, and a directory outside any repository stands alone.
67
+ The `atc_session_update` MCP tool renames and pins sessions, so an agent can organise the fleet for
68
+ you.
62
69
 
63
70
  Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
64
71
  to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
@@ -74,8 +81,8 @@ status bar only on the home and overlay screens.
74
81
  Spawned sessions get a `--settings` file injecting `Notification`, `Stop`, `UserPromptSubmit`, and
75
82
  `SessionEnd` hooks that report to a unix socket (`$XDG_RUNTIME_DIR/atc.sock`). Your global Claude
76
83
  settings are untouched; sessions you start outside atc are unaffected. States: red `●` needs you,
77
- cyan `◐` running, green `✓` turn done, gray `✗` exited. The overlay sorts needs-you first; the
78
- status bar turns red and names the most urgent session.
84
+ cyan `◐` running, green `✓` turn done, gray `✗` exited. The status bar turns red and names the most
85
+ urgent session.
79
86
 
80
87
  ## Config
81
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
+ }
@@ -15,7 +15,6 @@ interface SpawnParams {
15
15
  readonly rows: number;
16
16
  readonly resume: boolean | string;
17
17
  readonly namedBy: 'user' | 'auto';
18
- readonly group?: string;
19
18
  }
20
19
 
21
20
  export interface DaemonContext {
@@ -25,7 +24,7 @@ export interface DaemonContext {
25
24
  readonly collectFleet: () => FleetEntry[];
26
25
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
27
26
  readonly killSession: (id: string) => boolean;
28
- readonly updateSession: (id: string, name?: string, group?: string) => boolean;
27
+ readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
29
28
  readonly quitDaemon: () => void;
30
29
  readonly ackSession: (id: string) => boolean;
31
30
  readonly buildResumeCommand: (id: string) => string | null;
@@ -225,9 +224,9 @@ export class DaemonConnection {
225
224
  case 'session.update': {
226
225
  const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
227
226
  const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
228
- const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : undefined;
227
+ const pinned = typeof req.p?.['pinned'] === 'boolean' ? req.p['pinned'] : undefined;
229
228
 
230
- if (this.ctx.updateSession(sessionID, name, group)) {
229
+ if (this.ctx.updateSession(sessionID, name, pinned)) {
231
230
  this.sendOk(req.id, {});
232
231
  } else {
233
232
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -370,8 +369,6 @@ export class DaemonConnection {
370
369
  resume = rawResume;
371
370
  }
372
371
 
373
- const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
374
-
375
372
  const session = this.ctx.spawnSession({
376
373
  cwd,
377
374
  name: name === '' ? basename(cwd) : name,
@@ -380,7 +377,6 @@ export class DaemonConnection {
380
377
  rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
381
378
  resume,
382
379
  namedBy: name === '' ? 'auto' : 'user',
383
- ...(group === '' ? {} : { group }),
384
380
  });
385
381
 
386
382
  this.sendOk(req.id, { session });
package/src/daemon.ts CHANGED
@@ -418,7 +418,8 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
418
418
  kind: s.kind,
419
419
  alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
420
420
  ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
421
- ...(s.group === undefined ? {} : { group: s.group }),
421
+ pinned: s.pinned,
422
+ lastAttachedAt: s.lastAttachedAt,
422
423
  }),
423
424
  renamed: () => ({
424
425
  v: PROTOCOL_V,
@@ -457,7 +458,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
457
458
  collectSpawnDirs: () => store.collectSpawnDirs(),
458
459
  collectFleet: () => store.loadFleet(),
459
460
  spawnSession: (p) => {
460
- const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.group);
461
+ const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
461
462
 
462
463
  ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
463
464
  screens.set(s.id, new ScreenModel(p.cols, p.rows));
@@ -465,7 +466,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
465
466
 
466
467
  return getDescriptor(mgr, s.id);
467
468
  },
468
- updateSession: (id, name, group) => mgr.updateSession(id, name, group),
469
+ updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
469
470
  quitDaemon: () => {
470
471
  // The ok response for the quit request must flush before the sockets
471
472
  // close under it.
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';
@@ -16,7 +17,6 @@ type Mode =
16
17
  | 'help'
17
18
  | 'picker-dir'
18
19
  | 'picker-name'
19
- | 'picker-group'
20
20
  | 'picker-prompt'
21
21
  | 'picker-eject';
22
22
 
@@ -24,7 +24,9 @@ interface MirrorSession {
24
24
  id: string;
25
25
  name: string;
26
26
  cwd: string;
27
- group?: string;
27
+ pinned: boolean;
28
+ lastAttachedAt: number;
29
+ repoRoot: string;
28
30
  state: SessionState;
29
31
  unread: boolean;
30
32
  lastMsg: string;
@@ -55,7 +57,6 @@ let pickerInput = '';
55
57
  let pickerSelected = 0;
56
58
  let spawnDir = '';
57
59
  let spawnName = '';
58
- let spawnGroup = '';
59
60
  let spawnResume = false;
60
61
  const stdout = process.stdout;
61
62
 
@@ -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
  }
@@ -201,8 +202,11 @@ function applyHelpKey(buf: Buffer) {
201
202
  // fzf-style overlay search: null when inactive, the pattern while active.
202
203
  let overlayFilter: string | null = null;
203
204
 
205
+ // Flat list by default; g toggles clustering under repository headers.
206
+ let overlayGrouped = false;
207
+
204
208
  function pickOverlaySessions(): MirrorSession[] {
205
- const sorted = sortGroupedSessionViews(fleet);
209
+ const sorted = overlayGrouped ? sortGroupedSessionViews(fleet) : sortSessionViews(fleet);
206
210
 
207
211
  if (overlayFilter === null || overlayFilter === '') {
208
212
  return sorted;
@@ -210,9 +214,7 @@ function pickOverlaySessions(): MirrorSession[] {
210
214
 
211
215
  const f = overlayFilter;
212
216
 
213
- return sorted.filter(
214
- (s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
215
- );
217
+ return sorted.filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null);
216
218
  }
217
219
 
218
220
  function renderOverlay() {
@@ -226,6 +228,7 @@ function renderOverlay() {
226
228
  confirmKill,
227
229
  filter: overlayFilter,
228
230
  stale: daemonStale,
231
+ grouped: overlayGrouped,
229
232
  });
230
233
 
231
234
  scheduleStatus();
@@ -236,11 +239,11 @@ function openOverlay() {
236
239
  confirmKill = false;
237
240
  overlayFilter = null;
238
241
 
239
- const focusedIndex = sortGroupedSessionViews(fleet).findIndex((s) => s.id === focusedID);
242
+ const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
240
243
 
241
244
  overlaySelected = Math.max(0, focusedIndex);
242
245
 
243
- stdout.write(ansi.clear);
246
+ stdout.write(ansi.resetInputModes + ansi.clear);
244
247
 
245
248
  renderOverlay();
246
249
  }
@@ -269,18 +272,6 @@ function renderPicker() {
269
272
  placeholder: basename(spawnDir),
270
273
  hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
271
274
  });
272
- } else if (mode === 'picker-group') {
273
- const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
274
- const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
275
-
276
- drawPicker({
277
- title: `${verb}: group`,
278
- items: [],
279
- selected: -1,
280
- input: pickerInput,
281
- placeholder: 'optional — ⏎ to skip',
282
- hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
283
- });
284
275
  } else if (mode === 'picker-eject') {
285
276
  drawPicker({
286
277
  title: 'eject: headless instruction',
@@ -306,7 +297,6 @@ function renderPicker() {
306
297
 
307
298
  async function openDirPicker(resume = false) {
308
299
  spawnResume = resume;
309
- spawnGroup = '';
310
300
 
311
301
  let recent: string[] = [];
312
302
 
@@ -354,7 +344,6 @@ async function spawnFromPicker(prompt: string) {
354
344
  cols: cols(),
355
345
  rows: ptyRows(),
356
346
  ...(spawnResume ? { resume: true } : {}),
357
- ...(spawnGroup === '' ? {} : { group: spawnGroup }),
358
347
  });
359
348
 
360
349
  const spawned = toMirrorSession(ok['session']);
@@ -375,7 +364,7 @@ function quit(code = 0): never {
375
364
  detachFocused();
376
365
 
377
366
  client.stop();
378
- stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
367
+ stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
379
368
 
380
369
  try {
381
370
  process.stdin.setRawMode(false);
@@ -410,7 +399,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
410
399
  id: value['id'],
411
400
  name: value['name'],
412
401
  cwd: value['cwd'],
413
- ...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
402
+ pinned: value['pinned'] === true,
403
+ lastAttachedAt:
404
+ typeof value['lastAttachedAt'] === 'number' ? value['lastAttachedAt'] : value['createdAt'],
405
+ repoRoot: typeof value['repoRoot'] === 'string' ? value['repoRoot'] : value['cwd'],
414
406
  state,
415
407
  unread: value['unread'],
416
408
  lastMsg: value['lastMsg'],
@@ -428,6 +420,9 @@ function upsertMirror(d: Readonly<MirrorSession>) {
428
420
  fleet.push({ ...d });
429
421
  } else {
430
422
  existing.name = d.name;
423
+ existing.pinned = d.pinned;
424
+ existing.lastAttachedAt = d.lastAttachedAt;
425
+ existing.repoRoot = d.repoRoot;
431
426
  existing.state = d.state;
432
427
  existing.unread = d.unread;
433
428
  existing.lastMsg = d.lastMsg;
@@ -496,12 +491,12 @@ function applyDaemonEvent(e: EventMsg) {
496
491
  s.kind = e['kind'];
497
492
  }
498
493
 
499
- if (typeof e['group'] === 'string') {
500
- if (e['group'] === '') {
501
- delete s.group;
502
- } else {
503
- s.group = e['group'];
504
- }
494
+ if (typeof e['pinned'] === 'boolean') {
495
+ s.pinned = e['pinned'];
496
+ }
497
+
498
+ if (typeof e['lastAttachedAt'] === 'number') {
499
+ s.lastAttachedAt = e['lastAttachedAt'];
505
500
  }
506
501
 
507
502
  if (typeof e['unread'] === 'boolean') {
@@ -572,6 +567,7 @@ function copyToClipboard(text: string) {
572
567
  // ---- input ----
573
568
 
574
569
  const leader = loadConfig().leader;
570
+ const leaderChords = buildLeaderChords(leader.code);
575
571
 
576
572
  const KEY = {
577
573
  tab: 0x09,
@@ -590,6 +586,12 @@ function isDown(buf: Buffer): boolean {
590
586
  return buf.toString() === '\u001B[B' || buf.toString() === '\u001BOB';
591
587
  }
592
588
 
589
+ // A fullscreen session can leave the terminal in an enhanced keyboard
590
+ // encoding, so the leader arrives as a CSI chord instead of its bare byte.
591
+ function isLeaderKey(buf: Buffer): boolean {
592
+ return leaderChords.includes(buf.toString());
593
+ }
594
+
593
595
  function applyOverlayFilterKey(buf: Buffer): boolean {
594
596
  if (overlayFilter === null) {
595
597
  return false;
@@ -627,7 +629,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
627
629
  return true;
628
630
  }
629
631
 
630
- if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === leader.code) {
632
+ if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || isLeaderKey(buf)) {
631
633
  return false;
632
634
  }
633
635
 
@@ -685,7 +687,7 @@ function applyOverlayKey(buf: Buffer) {
685
687
  return;
686
688
  }
687
689
 
688
- if (buf[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
690
+ if (isLeaderKey(buf) || (buf[0] === KEY.esc && buf.length === 1)) {
689
691
  toBase();
690
692
 
691
693
  return;
@@ -738,6 +740,26 @@ function applyOverlayKey(buf: Buffer) {
738
740
  return;
739
741
  }
740
742
 
743
+ if (ch === 'g') {
744
+ overlayGrouped = !overlayGrouped;
745
+
746
+ stdout.write(ansi.clear);
747
+
748
+ renderOverlay();
749
+
750
+ return;
751
+ }
752
+
753
+ if (ch === 'p' && sel !== undefined) {
754
+ // Flipped locally too so the repaint is immediate; the daemon's state
755
+ // event confirms it.
756
+ sel.pinned = !sel.pinned;
757
+ void sendQuiet('session.update', { session: sel.id, pinned: sel.pinned });
758
+ renderOverlay();
759
+
760
+ return;
761
+ }
762
+
741
763
  if (ch === '?') {
742
764
  openHelp();
743
765
 
@@ -843,7 +865,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
843
865
  return;
844
866
  }
845
867
 
846
- if (buf[0] === leader.code) {
868
+ if (isLeaderKey(buf)) {
847
869
  toBase();
848
870
 
849
871
  return;
@@ -985,7 +1007,7 @@ const stdinDecoder = new TextDecoder('utf-8');
985
1007
  process.stdin.on('data', (buf: Buffer) => {
986
1008
  switch (mode) {
987
1009
  case 'attached': {
988
- if (buf[0] === leader.code && buf.length === 1) {
1010
+ if (isLeaderKey(buf)) {
989
1011
  openOverlay();
990
1012
 
991
1013
  return;
@@ -1001,7 +1023,7 @@ process.stdin.on('data', (buf: Buffer) => {
1001
1023
  return;
1002
1024
  }
1003
1025
  case 'home': {
1004
- if (buf[0] === leader.code) {
1026
+ if (isLeaderKey(buf)) {
1005
1027
  openOverlay();
1006
1028
 
1007
1029
  return;
@@ -1080,30 +1102,6 @@ process.stdin.on('data', (buf: Buffer) => {
1080
1102
  () => {
1081
1103
  spawnName = pickerInput.trim();
1082
1104
  pickerInput = '';
1083
- mode = 'picker-group';
1084
-
1085
- stdout.write(ansi.clear);
1086
-
1087
- renderPicker();
1088
- },
1089
- () => {
1090
- pickerInput = '';
1091
- mode = 'picker-dir';
1092
-
1093
- stdout.write(ansi.clear);
1094
-
1095
- renderPicker();
1096
- },
1097
- );
1098
-
1099
- return;
1100
- }
1101
- case 'picker-group': {
1102
- applyTextKey(
1103
- buf,
1104
- () => {
1105
- spawnGroup = pickerInput.trim();
1106
- pickerInput = '';
1107
1105
 
1108
1106
  // Adopt skips the prompt step: claude --resume opens its own
1109
1107
  // session picker inside the new PTY.
@@ -1121,7 +1119,7 @@ process.stdin.on('data', (buf: Buffer) => {
1121
1119
  },
1122
1120
  () => {
1123
1121
  pickerInput = '';
1124
- mode = 'picker-name';
1122
+ mode = 'picker-dir';
1125
1123
 
1126
1124
  stdout.write(ansi.clear);
1127
1125
 
@@ -1139,7 +1137,7 @@ process.stdin.on('data', (buf: Buffer) => {
1139
1137
  },
1140
1138
  () => {
1141
1139
  pickerInput = '';
1142
- mode = 'picker-group';
1140
+ mode = 'picker-name';
1143
1141
 
1144
1142
  stdout.write(ansi.clear);
1145
1143
 
@@ -1188,7 +1186,7 @@ stdout.on('resize', () => {
1188
1186
  });
1189
1187
 
1190
1188
  process.on('uncaughtException', (err) => {
1191
- stdout.write(ansi.showCursor + ansi.altScreenOff + ansi.reset);
1189
+ stdout.write(ansi.resetInputModes + ansi.showCursor + ansi.altScreenOff + ansi.reset);
1192
1190
 
1193
1191
  try {
1194
1192
  process.stdin.setRawMode(false);
package/src/mcp-server.ts CHANGED
@@ -72,13 +72,13 @@ const TOOLS: readonly MCPTool[] = [
72
72
  {
73
73
  name: 'atc_session_update',
74
74
  description:
75
- 'Rename and/or regroup a session. Renames stick against auto-summaries; an empty group clears the grouping. Use this to organise the fleet: name sessions after their task and group related work.',
75
+ 'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. Use this to organise the fleet: name sessions after their task.',
76
76
  inputSchema: {
77
77
  type: 'object',
78
78
  properties: {
79
79
  session: { type: 'string', description: 'The atc session id' },
80
80
  name: { type: 'string', description: 'New display name; omit to keep' },
81
- group: { type: 'string', description: 'New group; empty string clears, omit to keep' },
81
+ pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
82
82
  },
83
83
  required: ['session'],
84
84
  additionalProperties: false,
@@ -258,7 +258,7 @@ async function runTool(
258
258
  await client.sendRequest('session.update', {
259
259
  session: args['session'],
260
260
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
261
- ...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
261
+ ...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
262
262
  });
263
263
 
264
264
  return 'updated';
@@ -4,6 +4,8 @@ import { sortSessionViews } from './sessions';
4
4
  interface TabCandidate {
5
5
  readonly id: string;
6
6
  readonly state: SessionState;
7
+ readonly pinned: boolean;
8
+ readonly lastAttachedAt: number;
7
9
  readonly createdAt: number;
8
10
  readonly alive: boolean;
9
11
  readonly kind: 'pty' | 'jsonl';
@@ -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';
@@ -0,0 +1,51 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * The repository a directory belongs to, for clustering sessions in the
6
+ * overlay. Walks toward the filesystem root looking for a `.git` entry: a
7
+ * directory marks the repository root itself, and a linked worktree's `.git`
8
+ * file points back at the main repository, so worktrees cluster with it. A
9
+ * directory outside any repository resolves to itself.
10
+ */
11
+ export function resolveRepoRoot(cwd: string): string {
12
+ let dir = cwd;
13
+
14
+ while (true) {
15
+ const marker = join(dir, '.git');
16
+ const stat = statSync(marker, { throwIfNoEntry: false });
17
+
18
+ if (stat !== undefined && stat.isDirectory()) {
19
+ return dir;
20
+ }
21
+
22
+ if (stat !== undefined && stat.isFile()) {
23
+ return findMainRoot(marker) ?? dir;
24
+ }
25
+
26
+ const parent = dirname(dir);
27
+
28
+ if (parent === dir) {
29
+ return cwd;
30
+ }
31
+
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ // A linked worktree's `.git` file reads `gitdir: <main>/.git/worktrees/<name>`.
37
+ function findMainRoot(gitFile: string): string | null {
38
+ try {
39
+ const gitdir = /^gitdir:\s*(?<dir>.+)$/mu
40
+ .exec(readFileSync(gitFile, 'utf8'))
41
+ ?.groups?.['dir']?.trim();
42
+
43
+ if (gitdir === undefined) {
44
+ return null;
45
+ }
46
+
47
+ return /^(?<root>.+)\/\.git\/worktrees\/[^/]+$/u.exec(gitdir)?.groups?.['root'] ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -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/sessions.ts CHANGED
@@ -7,6 +7,7 @@ import { collectCleanEnv } from './collect-clean-env';
7
7
  import { socketPath, stateDir, statusFile } from './config';
8
8
  import type { HookEvent } from './hooks';
9
9
  import { isRecord } from './report';
10
+ import { resolveRepoRoot } from './resolve-repo-root';
10
11
 
11
12
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
12
13
 
@@ -23,7 +24,9 @@ export interface SessionDescriptor {
23
24
  readonly lastMsg: string;
24
25
  readonly lastDetail?: string;
25
26
  readonly claudeId?: string;
26
- readonly group?: string;
27
+ readonly pinned: boolean;
28
+ readonly lastAttachedAt: number;
29
+ readonly repoRoot: string;
27
30
  readonly namedBy: 'user' | 'auto' | 'agent';
28
31
  readonly createdAt: number;
29
32
  readonly kind: 'pty' | 'jsonl';
@@ -42,7 +45,15 @@ export interface Session {
42
45
  lastDetail?: string;
43
46
  claudeId?: string;
44
47
  transcriptSource?: string;
45
- group?: string;
48
+ pinned: boolean;
49
+
50
+ // when the operator last attached, so lists can lead with the sessions
51
+ // they were just working in; starts at creation time.
52
+ lastAttachedAt: number;
53
+
54
+ // the repository this session's directory belongs to (the directory
55
+ // itself outside any repository), for clustering in the overlay.
56
+ repoRoot: string;
46
57
 
47
58
  // who last named this session: the agent's own rename beats everything, a
48
59
  // user-typed spawn name beats auto-summaries.
@@ -56,7 +67,8 @@ export interface FleetEntry {
56
67
  readonly name: string;
57
68
  readonly cwd: string;
58
69
  readonly claudeId: string;
59
- readonly group?: string;
70
+ readonly pinned?: boolean;
71
+ readonly lastAttachedAt?: number;
60
72
  }
61
73
 
62
74
  const fleetFile = join(stateDir, 'fleet.json');
@@ -155,7 +167,9 @@ export class SessionManager {
155
167
  unread: false,
156
168
  lastMsg: 'waiting to restore',
157
169
  claudeId: entry.claudeId,
158
- ...(entry.group === undefined ? {} : { group: entry.group }),
170
+ pinned: entry.pinned ?? false,
171
+ lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
172
+ repoRoot: resolveRepoRoot(entry.cwd),
159
173
  namedBy: 'auto',
160
174
  createdAt: Date.now(),
161
175
  };
@@ -215,13 +229,12 @@ export class SessionManager {
215
229
  return s;
216
230
  }
217
231
 
218
- // Reports a headless run's lifecycle into the session state machine.
219
232
  /**
220
- * Renames and/or regroups a session on a caller's behalf. A rename lands
221
- * at user strength, so auto-summaries stop overwriting it while an
222
- * in-session rename still wins. An empty group clears the grouping.
233
+ * Renames and/or pins a session on a caller's behalf. A rename lands at
234
+ * user strength, so auto-summaries stop overwriting it while an
235
+ * in-session rename still wins. Pinned sessions lead every list.
223
236
  */
224
- updateSession(id: string, name?: string, group?: string): boolean {
237
+ updateSession(id: string, name?: string, pinned?: boolean): boolean {
225
238
  const s = this.sessions.find((x) => x.id === id);
226
239
 
227
240
  if (s === undefined) {
@@ -235,12 +248,8 @@ export class SessionManager {
235
248
  this.onEvent('renamed', s);
236
249
  }
237
250
 
238
- if (group !== undefined) {
239
- if (group === '') {
240
- delete s.group;
241
- } else {
242
- s.group = group;
243
- }
251
+ if (pinned !== undefined && pinned !== s.pinned) {
252
+ s.pinned = pinned;
244
253
 
245
254
  this.onEvent('state', s);
246
255
  }
@@ -305,7 +314,6 @@ export class SessionManager {
305
314
  rows: number,
306
315
  resume: boolean | string = false,
307
316
  namedBy: 'user' | 'auto' = 'auto',
308
- group?: string,
309
317
  ): Session {
310
318
  const id = `s${++counter}-${Date.now().toString(36)}`;
311
319
  const plan = this.adapter.planSpawn({ prompt, resume });
@@ -334,7 +342,9 @@ export class SessionManager {
334
342
  unread: false,
335
343
  lastMsg: initialMsg,
336
344
  ...(typeof resume === 'string' ? { claudeId: resume } : {}),
337
- ...(group === undefined ? {} : { group }),
345
+ pinned: false,
346
+ lastAttachedAt: Date.now(),
347
+ repoRoot: resolveRepoRoot(cwd),
338
348
  namedBy,
339
349
  createdAt: Date.now(),
340
350
  };
@@ -376,7 +386,9 @@ export class SessionManager {
376
386
  lastMsg: s.lastMsg,
377
387
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
378
388
  ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
379
- ...(s.group === undefined ? {} : { group: s.group }),
389
+ pinned: s.pinned,
390
+ lastAttachedAt: s.lastAttachedAt,
391
+ repoRoot: s.repoRoot,
380
392
  namedBy: s.namedBy,
381
393
  createdAt: s.createdAt,
382
394
  kind: s.kind,
@@ -508,6 +520,9 @@ export class SessionManager {
508
520
  }
509
521
 
510
522
  s.unread = false;
523
+ s.lastAttachedAt = Date.now();
524
+
525
+ this.writeFleet();
511
526
 
512
527
  // Attaching answers the attention request: a still-pending prompt
513
528
  // re-flags it via the next notification.
@@ -597,7 +612,8 @@ export class SessionManager {
597
612
  name: s.name,
598
613
  cwd: s.cwd,
599
614
  claudeId: s.claudeId,
600
- ...(s.group === undefined ? {} : { group: s.group }),
615
+ ...(s.pinned ? { pinned: true } : {}),
616
+ lastAttachedAt: s.lastAttachedAt,
601
617
  });
602
618
  }
603
619
  }
@@ -626,10 +642,17 @@ export function countSessionStates(
626
642
  return c;
627
643
  }
628
644
 
629
- // Overlay order: who needs you first, then finished turns, then busy, then dead.
630
- export function sortSessionViews<
631
- T extends { readonly state: SessionState; readonly createdAt: number },
632
- >(list: readonly T[]): T[] {
645
+ interface SortableSessionView {
646
+ readonly state: SessionState;
647
+ readonly pinned: boolean;
648
+ readonly lastAttachedAt: number;
649
+ readonly createdAt: number;
650
+ }
651
+
652
+ // Overlay order: pinned sessions first in most-recently-attached order, then
653
+ // everyone else by urgency — who needs you, finished turns, busy, dead —
654
+ // with most-recently-attached breaking ties inside each state.
655
+ export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
633
656
  const rank: Record<SessionState, number> = {
634
657
  needs_you: 0,
635
658
  done: 1,
@@ -637,24 +660,31 @@ export function sortSessionViews<
637
660
  exited: 3,
638
661
  };
639
662
 
640
- return [...list].toSorted((a, b) => rank[a.state] - rank[b.state] || a.createdAt - b.createdAt);
663
+ return [...list].toSorted((a, b) => {
664
+ if (a.pinned !== b.pinned) {
665
+ return a.pinned ? -1 : 1;
666
+ }
667
+
668
+ const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
669
+
670
+ return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
671
+ });
641
672
  }
642
673
 
643
- // Overlay display order: the urgency sort with each group's sessions pulled
644
- // together at the position of its most urgent member, so the renderer's
645
- // adjacency-based headers appear once per group and urgent groups still lead.
674
+ // Never a filesystem path, so a repository can't collide with it.
675
+ export const PINNED_GROUP_KEY = ' pinned';
676
+
677
+ // Overlay display order for the grouped view: the flat sort with each
678
+ // repository's sessions pulled together at the position of its best-ranked
679
+ // member, so the renderer's adjacency-based headers appear once per group.
680
+ // Pinned sessions form their own leading group.
646
681
  export function sortGroupedSessionViews<
647
- T extends {
648
- readonly state: SessionState;
649
- readonly createdAt: number;
650
- readonly group?: string;
651
- readonly cwd: string;
652
- },
682
+ T extends SortableSessionView & { readonly repoRoot: string },
653
683
  >(list: readonly T[]): T[] {
654
684
  const buckets = new Map<string, T[]>();
655
685
 
656
686
  for (const s of sortSessionViews(list)) {
657
- const key = s.group ?? s.cwd;
687
+ const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
658
688
  const bucket = buckets.get(key);
659
689
 
660
690
  if (bucket === undefined) {
@@ -24,7 +24,8 @@ export class StateStore {
24
24
  claude_id TEXT PRIMARY KEY,
25
25
  name TEXT NOT NULL,
26
26
  cwd TEXT NOT NULL,
27
- grp TEXT
27
+ pinned INTEGER NOT NULL DEFAULT 0,
28
+ last_attached INTEGER
28
29
  );
29
30
  CREATE TABLE IF NOT EXISTS events (
30
31
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -40,15 +41,21 @@ export class StateStore {
40
41
  );
41
42
  `);
42
43
 
43
- // Stores created before groups existed lack the column; the create
44
- // above only applies to fresh databases.
45
- const fleetColumns = this.db
46
- .query<{ name: string }, []>('PRAGMA table_info(fleet)')
47
- .all()
48
- .map((c) => c.name);
44
+ // Stores created before pinning and attach recency existed lack the
45
+ // columns; the create above only applies to fresh databases.
46
+ const fleetColumns = new Set(
47
+ this.db
48
+ .query<{ name: string }, []>('PRAGMA table_info(fleet)')
49
+ .all()
50
+ .map((c) => c.name),
51
+ );
52
+
53
+ if (!fleetColumns.has('pinned')) {
54
+ this.db.run('ALTER TABLE fleet ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
55
+ }
49
56
 
50
- if (!fleetColumns.includes('grp')) {
51
- this.db.run('ALTER TABLE fleet ADD COLUMN grp TEXT');
57
+ if (!fleetColumns.has('last_attached')) {
58
+ this.db.run('ALTER TABLE fleet ADD COLUMN last_attached INTEGER');
52
59
  }
53
60
 
54
61
  if (legacyFleetPath !== undefined) {
@@ -58,20 +65,28 @@ export class StateStore {
58
65
 
59
66
  loadFleet(): FleetEntry[] {
60
67
  const rows = this.db
61
- .query<{ claude_id: string; name: string; cwd: string; grp: string | null }, []>(
62
- 'SELECT claude_id, name, cwd, grp FROM fleet',
63
- )
68
+ .query<
69
+ {
70
+ claude_id: string;
71
+ name: string;
72
+ cwd: string;
73
+ pinned: number;
74
+ last_attached: number | null;
75
+ },
76
+ []
77
+ >('SELECT claude_id, name, cwd, pinned, last_attached FROM fleet')
64
78
  .all();
65
79
 
66
80
  const entries: FleetEntry[] = [];
67
81
 
68
82
  for (const row of rows) {
69
- const entry: FleetEntry =
70
- row.grp === null
71
- ? { claudeId: row.claude_id, name: row.name, cwd: row.cwd }
72
- : { claudeId: row.claude_id, name: row.name, cwd: row.cwd, group: row.grp };
73
-
74
- entries.push(entry);
83
+ entries.push({
84
+ claudeId: row.claude_id,
85
+ name: row.name,
86
+ cwd: row.cwd,
87
+ ...(row.pinned === 0 ? {} : { pinned: true }),
88
+ ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
89
+ });
75
90
  }
76
91
 
77
92
  return entries;
@@ -94,11 +109,13 @@ export class StateStore {
94
109
  this.db.run('DELETE FROM fleet');
95
110
 
96
111
  const insert = this.db.query(
97
- 'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, grp) VALUES (?1, ?2, ?3, ?4)',
112
+ 'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, pinned, last_attached) VALUES (?1, ?2, ?3, ?4, ?5)',
98
113
  );
99
114
 
100
115
  for (const entry of all) {
101
- insert.run(entry.claudeId, entry.name, entry.cwd, entry.group ?? null);
116
+ const pinned = entry.pinned === true ? 1 : 0;
117
+
118
+ insert.run(entry.claudeId, entry.name, entry.cwd, pinned, entry.lastAttachedAt ?? null);
102
119
  }
103
120
  });
104
121
 
package/src/ui.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { formatDir } from './dirs';
2
+ import { RESET_INPUT_MODES } from './reset-input-modes';
3
+ import { PINNED_GROUP_KEY } from './sessions';
2
4
  import type { SessionState } from './sessions';
3
5
 
4
6
  // The slice of a session the drawing layer needs; satisfied by both the
@@ -15,6 +17,7 @@ const ESC = '\u001B';
15
17
 
16
18
  export const ansi = {
17
19
  altScreenOn: `${ESC}[?1049h`,
20
+ resetInputModes: RESET_INPUT_MODES,
18
21
  altScreenOff: `${ESC}[?1049l`,
19
22
  clear: `${ESC}[2J${ESC}[H`,
20
23
  hideCursor: `${ESC}[?25l`,
@@ -158,7 +161,8 @@ export interface OverlaySessionView extends SessionView {
158
161
  readonly alive: boolean;
159
162
  readonly kind: 'pty' | 'jsonl';
160
163
  readonly resumable: boolean;
161
- readonly group?: string;
164
+ readonly pinned: boolean;
165
+ readonly repoRoot: string;
162
166
  }
163
167
 
164
168
  export interface OverlayView {
@@ -167,6 +171,7 @@ export interface OverlayView {
167
171
  confirmKill: boolean;
168
172
  filter: string | null;
169
173
  stale: boolean;
174
+ grouped: boolean;
170
175
  }
171
176
 
172
177
  export function drawOverlay(view: OverlayView) {
@@ -179,32 +184,32 @@ export function drawOverlay(view: OverlayView) {
179
184
  rowsList.push(dimRow(width, empty));
180
185
  }
181
186
 
182
- // Sessions cluster under dim headers when more than one group exists: an
183
- // explicit group wins, sessions without one fall back to their spawn
184
- // directory. The rows keep the sorted order, so urgent groups lead.
185
- const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
187
+ // The grouped view clusters sessions under dim repository headers, with
188
+ // pinned sessions leading in their own cluster; the flat view shows a
189
+ // directory column instead. The rows keep the sorted order either way.
186
190
  let lastKey: string | null = null;
187
191
 
188
192
  for (const [i, s] of view.sessions.entries()) {
189
- const key = s.group ?? s.cwd;
193
+ const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
190
194
 
191
- if (grouped && key !== lastKey) {
195
+ if (view.grouped && key !== lastKey) {
192
196
  lastKey = key;
193
197
 
194
- rowsList.push(dimRow(width, `▸ ${s.group ?? formatDir(s.cwd)}`));
198
+ rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
195
199
  }
196
200
 
197
201
  const sel = i === view.selected;
198
202
  const name = truncate(s.name, 16).padEnd(16);
199
203
  const state = STATE_LABEL[s.state].padEnd(9);
200
- const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
201
- const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - (grouped ? 0 : 19));
204
+ const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
205
+ const msgWidth = Math.max(4, width - 4 - 3 - 17 - 10 - (view.grouped ? 0 : 19));
202
206
  const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
203
207
  const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
208
+ const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
204
209
  const body = `${name} ${state}${dir} ${msg}`;
205
210
  const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
206
211
 
207
- rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
212
+ rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledBody}`, 3 + body.length));
208
213
  }
209
214
 
210
215
  rowsList.push(boxDivider(width));
@@ -237,7 +242,7 @@ export function drawOverlay(view: OverlayView) {
237
242
  drawBox(rowsList);
238
243
  }
239
244
 
240
- const GLOBAL_HINT = 'n new · ? keys';
245
+ const GLOBAL_HINT = 'g groups · n new · ? keys';
241
246
 
242
247
  // Only the actions valid for the selected row appear; the full reference
243
248
  // lives behind ?.
@@ -266,6 +271,10 @@ function buildOverlayHint(s: OverlaySessionView | undefined): string {
266
271
  actions.push('K forget');
267
272
  }
268
273
 
274
+ const pinAction = s.pinned ? 'p unpin' : 'p pin';
275
+
276
+ actions.push(pinAction);
277
+
269
278
  return `${actions.join(' · ')} ▏ ${GLOBAL_HINT}`;
270
279
  }
271
280
 
@@ -281,6 +290,8 @@ export function drawHelp() {
281
290
  'y yank the resume command to the clipboard',
282
291
  'Y yank the resume command, then kill here',
283
292
  'K kill (K again on a dead session forgets it)',
293
+ 'p pin or unpin — pinned sessions stay on top',
294
+ 'g toggle grouping by repository',
284
295
  'n new session',
285
296
  'r adopt an external session',
286
297
  '/ filter · ↑↓/jk move · q quit',