@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 +15 -8
- package/package.json +1 -1
- package/src/build-leader-chords.ts +27 -0
- package/src/daemon-connection.ts +3 -7
- package/src/daemon.ts +4 -3
- package/src/index.ts +62 -64
- package/src/mcp-server.ts +3 -3
- package/src/pick-tab-target.ts +2 -0
- package/src/reset-input-modes.ts +11 -0
- package/src/resolve-repo-root.ts +51 -0
- package/src/screen-model.ts +75 -1
- package/src/sessions.ts +64 -34
- package/src/state-store.ts +37 -20
- package/src/ui.ts +23 -12
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
78
|
-
|
|
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
|
@@ -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/daemon-connection.ts
CHANGED
|
@@ -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,
|
|
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
|
|
227
|
+
const pinned = typeof req.p?.['pinned'] === 'boolean' ? req.p['pinned'] : undefined;
|
|
229
228
|
|
|
230
|
-
if (this.ctx.updateSession(sessionID, name,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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['
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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['
|
|
261
|
+
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
return 'updated';
|
package/src/pick-tab-target.ts
CHANGED
|
@@ -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
|
+
}
|
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/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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
221
|
-
*
|
|
222
|
-
* in-session rename still wins.
|
|
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,
|
|
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 (
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
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.
|
|
687
|
+
const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
|
|
658
688
|
const bucket = buckets.get(key);
|
|
659
689
|
|
|
660
690
|
if (bucket === undefined) {
|
package/src/state-store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
44
|
-
// above only applies to fresh databases.
|
|
45
|
-
const fleetColumns =
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
51
|
-
this.db.run('ALTER TABLE fleet ADD COLUMN
|
|
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<
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
row.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// directory. The rows keep the sorted order
|
|
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.
|
|
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.
|
|
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 -
|
|
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}`,
|
|
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',
|