@zgeoff/atc 0.1.0 → 0.1.2
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 +46 -12
- package/package.json +1 -1
- package/src/config.ts +58 -1
- package/src/daemon-connection.ts +18 -0
- package/src/daemon.ts +13 -2
- package/src/dirs.ts +56 -20
- package/src/index.ts +81 -15
- package/src/mcp-server.ts +24 -0
- package/src/sessions.ts +48 -1
- package/src/state-store.ts +29 -6
- package/src/ui.ts +23 -6
package/README.md
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
mouse.
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
|
+
<p>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@zgeoff/atc"><img src="https://img.shields.io/npm/v/%40zgeoff%2Fatc" alt="npm version"></a>
|
|
12
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
10
15
|
<p>
|
|
11
16
|
<a href="./docs/README.md">Documentation</a> •
|
|
12
17
|
<a href="./docs/architecture/overview.md">Architecture</a> •
|
|
@@ -14,12 +19,16 @@
|
|
|
14
19
|
</p>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
|
-
##
|
|
22
|
+
## Install
|
|
18
23
|
|
|
19
24
|
```sh
|
|
20
|
-
bun
|
|
25
|
+
bun add -g @zgeoff/atc
|
|
26
|
+
atc
|
|
21
27
|
```
|
|
22
28
|
|
|
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.
|
|
31
|
+
|
|
23
32
|
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
24
33
|
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
25
34
|
fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches atc).
|
|
@@ -28,13 +37,14 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
28
37
|
|
|
29
38
|
| Key | Where | Action |
|
|
30
39
|
| --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
|
31
|
-
|
|
|
32
|
-
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt
|
|
40
|
+
| 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 |
|
|
33
42
|
| `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
|
|
34
43
|
| `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
|
|
35
44
|
| `j`/`k`/`↑`/`↓` | overlay/picker | move |
|
|
36
45
|
| `Enter` | overlay | attach (auto-acks) |
|
|
37
|
-
|
|
|
46
|
+
| `Tab` | overlay | attach the most urgent needs-you session |
|
|
47
|
+
| `/` | overlay | fuzzy filter by name/dir (chars in order), `⏎` attach top match, `esc` clear |
|
|
38
48
|
| `a` | overlay | ack notification without attaching |
|
|
39
49
|
| `H` | overlay | eject to headless: the terminal dies, a headless Agent SDK run resumes the same session |
|
|
40
50
|
| `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
|
|
@@ -44,6 +54,11 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
44
54
|
| `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
|
|
45
55
|
| `q` | home/overlay | quit the client — sessions keep running in the daemon |
|
|
46
56
|
|
|
57
|
+
The overlay clusters sessions under dim headers when more than one group exists: a session's
|
|
58
|
+
explicit group wins, and sessions without one fall back to the directory they were spawned from.
|
|
59
|
+
Groups also come from outside — the `atc_session_update` MCP tool renames and regroups sessions, so
|
|
60
|
+
an agent can organise the fleet for you.
|
|
61
|
+
|
|
47
62
|
Revive (`P`) and headless eject (`H`) resume the session from its saved transcript, so both need one
|
|
48
63
|
to exist: a session killed before its first exchange has nothing on disk yet, and the overlay says
|
|
49
64
|
so in its message column instead of resuming.
|
|
@@ -63,17 +78,36 @@ status bar turns red and names the most urgent session.
|
|
|
63
78
|
|
|
64
79
|
## Config
|
|
65
80
|
|
|
66
|
-
`~/.config/atc/config.json
|
|
81
|
+
`~/.config/atc/config.json` (created with defaults on first run):
|
|
67
82
|
|
|
68
83
|
```json
|
|
69
|
-
{
|
|
84
|
+
{
|
|
85
|
+
"claudeBin": "claude",
|
|
86
|
+
"claudeArgs": [],
|
|
87
|
+
"leader": "ctrl-space"
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Field | Default | Meaning |
|
|
92
|
+
| ------------ | -------------- | ------------------------------------------------------------------------------------- |
|
|
93
|
+
| `claudeBin` | `"claude"` | The binary spawned for every session. |
|
|
94
|
+
| `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`. |
|
|
95
|
+
| `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
|
|
96
|
+
|
|
97
|
+
Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
|
|
98
|
+
`ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
|
|
99
|
+
unknown or reserved value falls back to the default.
|
|
100
|
+
|
|
101
|
+
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
|
|
102
|
+
sessions included:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
claude mcp add --scope user atc -- atc mcp
|
|
70
106
|
```
|
|
71
107
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(SQLite), next to `status.json` (read by the injected statusline); the daemon's pid file sits in
|
|
76
|
-
`$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
108
|
+
Daemon state — the restorable fleet, spawn-dir history, and the hook-event trail — lives in
|
|
109
|
+
`~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected statusline); the
|
|
110
|
+
daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
77
111
|
|
|
78
112
|
## Crash safety
|
|
79
113
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -6,11 +6,18 @@ import { isRecord } from './report';
|
|
|
6
6
|
export interface Config {
|
|
7
7
|
claudeBin: string;
|
|
8
8
|
claudeArgs: string[];
|
|
9
|
+
leader: LeaderKey;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LeaderKey {
|
|
13
|
+
readonly code: number;
|
|
14
|
+
readonly label: string;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
const DEFAULTS: Config = {
|
|
12
18
|
claudeBin: 'claude',
|
|
13
19
|
claudeArgs: [],
|
|
20
|
+
leader: { code: 0, label: '^Space' },
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
const configDir = join(homedir(), '.config', 'atc');
|
|
@@ -50,8 +57,58 @@ export function loadConfig(): Config {
|
|
|
50
57
|
? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
|
|
51
58
|
: DEFAULTS.claudeArgs;
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
const leader =
|
|
61
|
+
(typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
|
|
62
|
+
DEFAULTS.leader;
|
|
63
|
+
|
|
64
|
+
return { claudeBin, claudeArgs, leader };
|
|
54
65
|
} catch {
|
|
55
66
|
return { ...DEFAULTS };
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
|
|
70
|
+
// Control bytes the terminal needs for its own input: enter, tab, and esc
|
|
71
|
+
// as leaders would swallow ordinary typing.
|
|
72
|
+
const RESERVED_CODES = new Set([0x09, 0x0d, 0x1b]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decodes a leader name like "ctrl-space", "ctrl-]", or "ctrl-a" into its
|
|
76
|
+
* control byte and status-bar label. Unknown or reserved keys decode to
|
|
77
|
+
* null, so a bad config falls back to the default instead of breaking the
|
|
78
|
+
* client.
|
|
79
|
+
*/
|
|
80
|
+
function decodeLeader(name: string): LeaderKey | null {
|
|
81
|
+
const m = /^ctrl-(?<key>.+)$/i.exec(name.trim().toLowerCase());
|
|
82
|
+
const key = m?.groups?.['key'];
|
|
83
|
+
|
|
84
|
+
if (key === undefined) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (key === 'space') {
|
|
89
|
+
return { code: 0, label: '^Space' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let code: number | null = null;
|
|
93
|
+
const cp = key.codePointAt(0);
|
|
94
|
+
|
|
95
|
+
if (/^[a-z]$/.test(key) && cp !== undefined) {
|
|
96
|
+
code = cp - 96;
|
|
97
|
+
} else if (key === '[') {
|
|
98
|
+
code = 0x1b;
|
|
99
|
+
} else if (key === '\\') {
|
|
100
|
+
code = 0x1c;
|
|
101
|
+
} else if (key === ']') {
|
|
102
|
+
code = 0x1d;
|
|
103
|
+
} else if (key === '^') {
|
|
104
|
+
code = 0x1e;
|
|
105
|
+
} else if (key === '_') {
|
|
106
|
+
code = 0x1f;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (code === null || RESERVED_CODES.has(code)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { code, label: `^${key.toUpperCase()}` };
|
|
114
|
+
}
|
package/src/daemon-connection.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface SpawnParams {
|
|
|
15
15
|
readonly rows: number;
|
|
16
16
|
readonly resume: boolean | string;
|
|
17
17
|
readonly namedBy: 'user' | 'auto';
|
|
18
|
+
readonly group?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface DaemonContext {
|
|
@@ -24,6 +25,7 @@ export interface DaemonContext {
|
|
|
24
25
|
readonly collectFleet: () => FleetEntry[];
|
|
25
26
|
readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
|
|
26
27
|
readonly killSession: (id: string) => boolean;
|
|
28
|
+
readonly updateSession: (id: string, name?: string, group?: string) => boolean;
|
|
27
29
|
readonly ackSession: (id: string) => boolean;
|
|
28
30
|
readonly buildResumeCommand: (id: string) => string | null;
|
|
29
31
|
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
@@ -213,6 +215,19 @@ export class DaemonConnection {
|
|
|
213
215
|
|
|
214
216
|
return;
|
|
215
217
|
}
|
|
218
|
+
case 'session.update': {
|
|
219
|
+
const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
|
|
220
|
+
const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
|
|
221
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : undefined;
|
|
222
|
+
|
|
223
|
+
if (this.ctx.updateSession(sessionID, name, group)) {
|
|
224
|
+
this.sendOk(req.id, {});
|
|
225
|
+
} else {
|
|
226
|
+
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
216
231
|
case 'session.kill': {
|
|
217
232
|
this.applySessionVerb(req, this.ctx.killSession);
|
|
218
233
|
|
|
@@ -348,6 +363,8 @@ export class DaemonConnection {
|
|
|
348
363
|
resume = rawResume;
|
|
349
364
|
}
|
|
350
365
|
|
|
366
|
+
const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
|
|
367
|
+
|
|
351
368
|
const session = this.ctx.spawnSession({
|
|
352
369
|
cwd,
|
|
353
370
|
name: name === '' ? basename(cwd) : name,
|
|
@@ -356,6 +373,7 @@ export class DaemonConnection {
|
|
|
356
373
|
rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
|
|
357
374
|
resume,
|
|
358
375
|
namedBy: name === '' ? 'auto' : 'user',
|
|
376
|
+
...(group === '' ? {} : { group }),
|
|
359
377
|
});
|
|
360
378
|
|
|
361
379
|
this.sendOk(req.id, { session });
|
package/src/daemon.ts
CHANGED
|
@@ -367,6 +367,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
367
367
|
kind: s.kind,
|
|
368
368
|
alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
|
|
369
369
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
370
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
370
371
|
}),
|
|
371
372
|
renamed: () => ({
|
|
372
373
|
v: PROTOCOL_V,
|
|
@@ -399,7 +400,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
399
400
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
400
401
|
collectFleet: () => store.loadFleet(),
|
|
401
402
|
spawnSession: (p) => {
|
|
402
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
403
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.group);
|
|
403
404
|
|
|
404
405
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
405
406
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
@@ -407,6 +408,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
407
408
|
|
|
408
409
|
return getDescriptor(mgr, s.id);
|
|
409
410
|
},
|
|
411
|
+
updateSession: (id, name, group) => mgr.updateSession(id, name, group),
|
|
410
412
|
killSession: (id) => {
|
|
411
413
|
if (!mgr.sessions.some((s) => s.id === id)) {
|
|
412
414
|
return false;
|
|
@@ -556,7 +558,16 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
556
558
|
continue;
|
|
557
559
|
}
|
|
558
560
|
|
|
559
|
-
const s = mgr.spawn(
|
|
561
|
+
const s = mgr.spawn(
|
|
562
|
+
entry.cwd,
|
|
563
|
+
entry.name,
|
|
564
|
+
'',
|
|
565
|
+
cols,
|
|
566
|
+
rows,
|
|
567
|
+
entry.claudeId,
|
|
568
|
+
'auto',
|
|
569
|
+
entry.group,
|
|
570
|
+
);
|
|
560
571
|
|
|
561
572
|
ptyDims.set(s.id, { cols, rows });
|
|
562
573
|
screens.set(s.id, new ScreenModel(cols, rows));
|
package/src/dirs.ts
CHANGED
|
@@ -38,26 +38,21 @@ export function pickMatches(items: readonly string[], filter: string): string[]
|
|
|
38
38
|
return [...items];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return { item, score };
|
|
57
|
-
})
|
|
58
|
-
.filter((x) => x.score >= 0);
|
|
59
|
-
|
|
60
|
-
return scored.toSorted((a, b) => a.score - b.score).map((x) => x.item);
|
|
41
|
+
const scored: { item: string; score: number }[] = [];
|
|
42
|
+
|
|
43
|
+
for (const item of items) {
|
|
44
|
+
// Basename hits beat full-path hits, so `vers` ranks ~/projects/vers
|
|
45
|
+
// above every path that merely passes through a vers directory.
|
|
46
|
+
const baseScore = findFuzzyScore(basename(item), filter);
|
|
47
|
+
const pathScore = findFuzzyScore(item, filter);
|
|
48
|
+
const score = baseScore === null ? pathScore : baseScore + 100;
|
|
49
|
+
|
|
50
|
+
if (score !== null) {
|
|
51
|
+
scored.push({ item, score });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return scored.toSorted((a, b) => b.score - a.score).map((x) => x.item);
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
export function formatDir(d: string): string {
|
|
@@ -65,3 +60,44 @@ export function formatDir(d: string): string {
|
|
|
65
60
|
|
|
66
61
|
return d.startsWith(home) ? `~${d.slice(home.length)}` : d;
|
|
67
62
|
}
|
|
63
|
+
|
|
64
|
+
const SEPARATORS = new Set(['/', '-', '_', '.', ' ']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scores how well a filter fuzzy-matches a candidate: every filter
|
|
68
|
+
* character must appear in order, consecutive characters and characters
|
|
69
|
+
* starting a word score higher, and a miss is null rather than zero so
|
|
70
|
+
* non-matches are distinguishable from weak matches.
|
|
71
|
+
*/
|
|
72
|
+
export function findFuzzyScore(candidate: string, filter: string): number | null {
|
|
73
|
+
const hay = candidate.toLowerCase();
|
|
74
|
+
const needle = filter.toLowerCase();
|
|
75
|
+
let score = 0;
|
|
76
|
+
let pos = 0;
|
|
77
|
+
let prevHit = -2;
|
|
78
|
+
|
|
79
|
+
for (const ch of needle) {
|
|
80
|
+
const hit = hay.indexOf(ch, pos);
|
|
81
|
+
|
|
82
|
+
if (hit === -1) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
score += 1;
|
|
87
|
+
|
|
88
|
+
if (hit === prevHit + 1) {
|
|
89
|
+
score += 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const before = hay[hit - 1];
|
|
93
|
+
|
|
94
|
+
if (hit === 0 || (before !== undefined && SEPARATORS.has(before))) {
|
|
95
|
+
score += 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
prevHit = hit;
|
|
99
|
+
pos = hit + 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return score;
|
|
103
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { bootDaemonClient } from './boot-daemon';
|
|
3
|
-
import {
|
|
3
|
+
import { loadConfig } from './config';
|
|
4
|
+
import { collectDirs, findFuzzyScore, formatDir, pickMatches } from './dirs';
|
|
4
5
|
import type { EventMsg } from './protocol';
|
|
5
6
|
import { isRecord } from './report';
|
|
6
7
|
import { countSessionStates, sortSessionViews } from './sessions';
|
|
@@ -14,6 +15,7 @@ type Mode =
|
|
|
14
15
|
| 'help'
|
|
15
16
|
| 'picker-dir'
|
|
16
17
|
| 'picker-name'
|
|
18
|
+
| 'picker-group'
|
|
17
19
|
| 'picker-prompt'
|
|
18
20
|
| 'picker-eject';
|
|
19
21
|
|
|
@@ -21,6 +23,7 @@ interface MirrorSession {
|
|
|
21
23
|
id: string;
|
|
22
24
|
name: string;
|
|
23
25
|
cwd: string;
|
|
26
|
+
group?: string;
|
|
24
27
|
state: SessionState;
|
|
25
28
|
unread: boolean;
|
|
26
29
|
lastMsg: string;
|
|
@@ -45,6 +48,7 @@ let pickerInput = '';
|
|
|
45
48
|
let pickerSelected = 0;
|
|
46
49
|
let spawnDir = '';
|
|
47
50
|
let spawnName = '';
|
|
51
|
+
let spawnGroup = '';
|
|
48
52
|
let spawnResume = false;
|
|
49
53
|
const stdout = process.stdout;
|
|
50
54
|
|
|
@@ -76,6 +80,7 @@ function scheduleStatus() {
|
|
|
76
80
|
counts: countSessionStates(fleet),
|
|
77
81
|
focusedName: focused === null ? null : focused.name,
|
|
78
82
|
urgentName: urgent === undefined ? null : urgent.name,
|
|
83
|
+
leaderLabel: leader.label,
|
|
79
84
|
});
|
|
80
85
|
}
|
|
81
86
|
}, 50);
|
|
@@ -127,7 +132,7 @@ function toBase() {
|
|
|
127
132
|
} else {
|
|
128
133
|
mode = 'home';
|
|
129
134
|
|
|
130
|
-
drawHome(fleetCount);
|
|
135
|
+
drawHome(fleetCount, leader.label);
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
scheduleStatus();
|
|
@@ -180,7 +185,7 @@ function openHelp() {
|
|
|
180
185
|
function applyHelpKey(buf: Buffer) {
|
|
181
186
|
const ch = buf.toString();
|
|
182
187
|
|
|
183
|
-
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] ===
|
|
188
|
+
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === leader.code) {
|
|
184
189
|
openOverlay();
|
|
185
190
|
}
|
|
186
191
|
}
|
|
@@ -195,9 +200,11 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
195
200
|
return sorted;
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
const f = overlayFilter
|
|
203
|
+
const f = overlayFilter;
|
|
199
204
|
|
|
200
|
-
return sorted.filter(
|
|
205
|
+
return sorted.filter(
|
|
206
|
+
(s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
|
|
207
|
+
);
|
|
201
208
|
}
|
|
202
209
|
|
|
203
210
|
function renderOverlay() {
|
|
@@ -253,6 +260,18 @@ function renderPicker() {
|
|
|
253
260
|
placeholder: basename(spawnDir),
|
|
254
261
|
hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
|
|
255
262
|
});
|
|
263
|
+
} else if (mode === 'picker-group') {
|
|
264
|
+
const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
|
|
265
|
+
const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
|
|
266
|
+
|
|
267
|
+
drawPicker({
|
|
268
|
+
title: `${verb}: group`,
|
|
269
|
+
items: [],
|
|
270
|
+
selected: -1,
|
|
271
|
+
input: pickerInput,
|
|
272
|
+
placeholder: 'optional — ⏎ to skip',
|
|
273
|
+
hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
|
|
274
|
+
});
|
|
256
275
|
} else if (mode === 'picker-eject') {
|
|
257
276
|
drawPicker({
|
|
258
277
|
title: 'eject: headless instruction',
|
|
@@ -278,6 +297,7 @@ function renderPicker() {
|
|
|
278
297
|
|
|
279
298
|
async function openDirPicker(resume = false) {
|
|
280
299
|
spawnResume = resume;
|
|
300
|
+
spawnGroup = '';
|
|
281
301
|
|
|
282
302
|
let recent: string[] = [];
|
|
283
303
|
|
|
@@ -325,6 +345,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
325
345
|
cols: cols(),
|
|
326
346
|
rows: ptyRows(),
|
|
327
347
|
...(spawnResume ? { resume: true } : {}),
|
|
348
|
+
...(spawnGroup === '' ? {} : { group: spawnGroup }),
|
|
328
349
|
});
|
|
329
350
|
|
|
330
351
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -380,6 +401,7 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
380
401
|
id: value['id'],
|
|
381
402
|
name: value['name'],
|
|
382
403
|
cwd: value['cwd'],
|
|
404
|
+
...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
|
|
383
405
|
state,
|
|
384
406
|
unread: value['unread'],
|
|
385
407
|
lastMsg: value['lastMsg'],
|
|
@@ -461,6 +483,14 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
461
483
|
s.kind = e['kind'];
|
|
462
484
|
}
|
|
463
485
|
|
|
486
|
+
if (typeof e['group'] === 'string') {
|
|
487
|
+
if (e['group'] === '') {
|
|
488
|
+
delete s.group;
|
|
489
|
+
} else {
|
|
490
|
+
s.group = e['group'];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
464
494
|
if (typeof e['unread'] === 'boolean') {
|
|
465
495
|
s.unread = e['unread'];
|
|
466
496
|
}
|
|
@@ -524,8 +554,10 @@ function copyToClipboard(text: string) {
|
|
|
524
554
|
|
|
525
555
|
// ---- input ----
|
|
526
556
|
|
|
557
|
+
const leader = loadConfig().leader;
|
|
558
|
+
|
|
527
559
|
const KEY = {
|
|
528
|
-
|
|
560
|
+
tab: 0x09,
|
|
529
561
|
ctrlC: 0x03,
|
|
530
562
|
ctrlU: 0x15,
|
|
531
563
|
esc: 0x1b,
|
|
@@ -578,7 +610,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
|
|
|
578
610
|
return true;
|
|
579
611
|
}
|
|
580
612
|
|
|
581
|
-
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] ===
|
|
613
|
+
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === leader.code) {
|
|
582
614
|
return false;
|
|
583
615
|
}
|
|
584
616
|
|
|
@@ -636,7 +668,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
636
668
|
return;
|
|
637
669
|
}
|
|
638
670
|
|
|
639
|
-
if (buf[0] ===
|
|
671
|
+
if (buf[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
|
|
640
672
|
toBase();
|
|
641
673
|
|
|
642
674
|
return;
|
|
@@ -660,6 +692,16 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
660
692
|
|
|
661
693
|
const ch = buf.toString();
|
|
662
694
|
|
|
695
|
+
if (buf[0] === KEY.tab) {
|
|
696
|
+
const needy = sortSessionViews(fleet).find((s) => s.state === 'needs_you' && s.alive);
|
|
697
|
+
|
|
698
|
+
if (needy !== undefined) {
|
|
699
|
+
void attach(needy.id);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
663
705
|
if (buf[0] === KEY.enter && sel !== undefined && sel.alive) {
|
|
664
706
|
void attach(sel.id);
|
|
665
707
|
|
|
@@ -778,7 +820,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
778
820
|
return;
|
|
779
821
|
}
|
|
780
822
|
|
|
781
|
-
if (buf[0] ===
|
|
823
|
+
if (buf[0] === leader.code) {
|
|
782
824
|
toBase();
|
|
783
825
|
|
|
784
826
|
return;
|
|
@@ -877,7 +919,7 @@ const stdinDecoder = new TextDecoder('utf-8');
|
|
|
877
919
|
process.stdin.on('data', (buf: Buffer) => {
|
|
878
920
|
switch (mode) {
|
|
879
921
|
case 'attached': {
|
|
880
|
-
if (buf[0] ===
|
|
922
|
+
if (buf[0] === leader.code && buf.length === 1) {
|
|
881
923
|
openOverlay();
|
|
882
924
|
|
|
883
925
|
return;
|
|
@@ -893,7 +935,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
893
935
|
return;
|
|
894
936
|
}
|
|
895
937
|
case 'home': {
|
|
896
|
-
if (buf[0] ===
|
|
938
|
+
if (buf[0] === leader.code) {
|
|
897
939
|
openOverlay();
|
|
898
940
|
|
|
899
941
|
return;
|
|
@@ -972,6 +1014,30 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
972
1014
|
() => {
|
|
973
1015
|
spawnName = pickerInput.trim();
|
|
974
1016
|
pickerInput = '';
|
|
1017
|
+
mode = 'picker-group';
|
|
1018
|
+
|
|
1019
|
+
stdout.write(ansi.clear);
|
|
1020
|
+
|
|
1021
|
+
renderPicker();
|
|
1022
|
+
},
|
|
1023
|
+
() => {
|
|
1024
|
+
pickerInput = '';
|
|
1025
|
+
mode = 'picker-dir';
|
|
1026
|
+
|
|
1027
|
+
stdout.write(ansi.clear);
|
|
1028
|
+
|
|
1029
|
+
renderPicker();
|
|
1030
|
+
},
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
case 'picker-group': {
|
|
1036
|
+
applyTextKey(
|
|
1037
|
+
buf,
|
|
1038
|
+
() => {
|
|
1039
|
+
spawnGroup = pickerInput.trim();
|
|
1040
|
+
pickerInput = '';
|
|
975
1041
|
|
|
976
1042
|
// Adopt skips the prompt step: claude --resume opens its own
|
|
977
1043
|
// session picker inside the new PTY.
|
|
@@ -989,7 +1055,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
989
1055
|
},
|
|
990
1056
|
() => {
|
|
991
1057
|
pickerInput = '';
|
|
992
|
-
mode = 'picker-
|
|
1058
|
+
mode = 'picker-name';
|
|
993
1059
|
|
|
994
1060
|
stdout.write(ansi.clear);
|
|
995
1061
|
|
|
@@ -1007,7 +1073,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1007
1073
|
},
|
|
1008
1074
|
() => {
|
|
1009
1075
|
pickerInput = '';
|
|
1010
|
-
mode = 'picker-
|
|
1076
|
+
mode = 'picker-group';
|
|
1011
1077
|
|
|
1012
1078
|
stdout.write(ansi.clear);
|
|
1013
1079
|
|
|
@@ -1037,7 +1103,7 @@ stdout.on('resize', () => {
|
|
|
1037
1103
|
}
|
|
1038
1104
|
|
|
1039
1105
|
if (mode === 'home') {
|
|
1040
|
-
drawHome(fleetCount);
|
|
1106
|
+
drawHome(fleetCount, leader.label);
|
|
1041
1107
|
}
|
|
1042
1108
|
|
|
1043
1109
|
if (mode === 'overlay') {
|
|
@@ -1072,5 +1138,5 @@ process.on('SIGHUP', () => quit());
|
|
|
1072
1138
|
// ---- start ----
|
|
1073
1139
|
stdout.write(ansi.altScreenOn);
|
|
1074
1140
|
|
|
1075
|
-
drawHome(fleetCount);
|
|
1141
|
+
drawHome(fleetCount, leader.label);
|
|
1076
1142
|
scheduleStatus();
|
package/src/mcp-server.ts
CHANGED
|
@@ -69,6 +69,21 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
69
69
|
additionalProperties: false,
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
name: 'atc_session_update',
|
|
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.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
session: { type: 'string', description: 'The atc session id' },
|
|
80
|
+
name: { type: 'string', description: 'New display name; omit to keep' },
|
|
81
|
+
group: { type: 'string', description: 'New group; empty string clears, omit to keep' },
|
|
82
|
+
},
|
|
83
|
+
required: ['session'],
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
72
87
|
{
|
|
73
88
|
name: 'atc_session_kill',
|
|
74
89
|
description: 'Kill a session. A second kill on a dead session removes it from the list.',
|
|
@@ -237,6 +252,15 @@ async function runTool(
|
|
|
237
252
|
|
|
238
253
|
return 'sent';
|
|
239
254
|
}
|
|
255
|
+
case 'atc_session_update': {
|
|
256
|
+
await client.sendRequest('session.update', {
|
|
257
|
+
session: args['session'],
|
|
258
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
259
|
+
...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return 'updated';
|
|
263
|
+
}
|
|
240
264
|
case 'atc_session_kill': {
|
|
241
265
|
await client.sendRequest('session.kill', { session: args['session'] });
|
|
242
266
|
|
package/src/sessions.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface SessionDescriptor {
|
|
|
23
23
|
readonly lastMsg: string;
|
|
24
24
|
readonly lastDetail?: string;
|
|
25
25
|
readonly claudeId?: string;
|
|
26
|
+
readonly group?: string;
|
|
26
27
|
readonly namedBy: 'user' | 'auto' | 'agent';
|
|
27
28
|
readonly createdAt: number;
|
|
28
29
|
readonly kind: 'pty' | 'jsonl';
|
|
@@ -41,6 +42,7 @@ export interface Session {
|
|
|
41
42
|
lastDetail?: string;
|
|
42
43
|
claudeId?: string;
|
|
43
44
|
transcriptSource?: string;
|
|
45
|
+
group?: string;
|
|
44
46
|
|
|
45
47
|
// who last named this session: the agent's own rename beats everything, a
|
|
46
48
|
// user-typed spawn name beats auto-summaries.
|
|
@@ -54,6 +56,7 @@ export interface FleetEntry {
|
|
|
54
56
|
readonly name: string;
|
|
55
57
|
readonly cwd: string;
|
|
56
58
|
readonly claudeId: string;
|
|
59
|
+
readonly group?: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const fleetFile = join(stateDir, 'fleet.json');
|
|
@@ -187,6 +190,42 @@ export class SessionManager {
|
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
// Reports a headless run's lifecycle into the session state machine.
|
|
193
|
+
/**
|
|
194
|
+
* Renames and/or regroups a session on a caller's behalf. A rename lands
|
|
195
|
+
* at user strength, so auto-summaries stop overwriting it while an
|
|
196
|
+
* in-session rename still wins. An empty group clears the grouping.
|
|
197
|
+
*/
|
|
198
|
+
updateSession(id: string, name?: string, group?: string): boolean {
|
|
199
|
+
const s = this.sessions.find((x) => x.id === id);
|
|
200
|
+
|
|
201
|
+
if (s === undefined) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (name !== undefined && name !== '' && s.namedBy !== 'agent') {
|
|
206
|
+
s.name = name;
|
|
207
|
+
s.namedBy = 'user';
|
|
208
|
+
|
|
209
|
+
this.onEvent('renamed', s);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (group !== undefined) {
|
|
213
|
+
if (group === '') {
|
|
214
|
+
delete s.group;
|
|
215
|
+
} else {
|
|
216
|
+
s.group = group;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this.onEvent('state', s);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.writeFleet();
|
|
223
|
+
this.writeStatus();
|
|
224
|
+
this.emitChange();
|
|
225
|
+
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
190
229
|
updateSurfaceState(id: string, state: SessionState, msg: string) {
|
|
191
230
|
const s = this.sessions.find((x) => x.id === id);
|
|
192
231
|
|
|
@@ -240,6 +279,7 @@ export class SessionManager {
|
|
|
240
279
|
rows: number,
|
|
241
280
|
resume: boolean | string = false,
|
|
242
281
|
namedBy: 'user' | 'auto' = 'auto',
|
|
282
|
+
group?: string,
|
|
243
283
|
): Session {
|
|
244
284
|
const id = `s${++counter}-${Date.now().toString(36)}`;
|
|
245
285
|
const plan = this.adapter.planSpawn({ prompt, resume });
|
|
@@ -268,6 +308,7 @@ export class SessionManager {
|
|
|
268
308
|
unread: false,
|
|
269
309
|
lastMsg: initialMsg,
|
|
270
310
|
...(typeof resume === 'string' ? { claudeId: resume } : {}),
|
|
311
|
+
...(group === undefined ? {} : { group }),
|
|
271
312
|
namedBy,
|
|
272
313
|
createdAt: Date.now(),
|
|
273
314
|
};
|
|
@@ -309,6 +350,7 @@ export class SessionManager {
|
|
|
309
350
|
lastMsg: s.lastMsg,
|
|
310
351
|
...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
|
|
311
352
|
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
353
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
312
354
|
namedBy: s.namedBy,
|
|
313
355
|
createdAt: s.createdAt,
|
|
314
356
|
kind: s.kind,
|
|
@@ -525,7 +567,12 @@ export class SessionManager {
|
|
|
525
567
|
const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
|
|
526
568
|
|
|
527
569
|
if (live && s.claudeId !== undefined) {
|
|
528
|
-
fleet.push({
|
|
570
|
+
fleet.push({
|
|
571
|
+
name: s.name,
|
|
572
|
+
cwd: s.cwd,
|
|
573
|
+
claudeId: s.claudeId,
|
|
574
|
+
...(s.group === undefined ? {} : { group: s.group }),
|
|
575
|
+
});
|
|
529
576
|
}
|
|
530
577
|
}
|
|
531
578
|
|
package/src/state-store.ts
CHANGED
|
@@ -23,7 +23,8 @@ export class StateStore {
|
|
|
23
23
|
CREATE TABLE IF NOT EXISTS fleet (
|
|
24
24
|
claude_id TEXT PRIMARY KEY,
|
|
25
25
|
name TEXT NOT NULL,
|
|
26
|
-
cwd TEXT NOT NULL
|
|
26
|
+
cwd TEXT NOT NULL,
|
|
27
|
+
grp TEXT
|
|
27
28
|
);
|
|
28
29
|
CREATE TABLE IF NOT EXISTS events (
|
|
29
30
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -39,6 +40,17 @@ export class StateStore {
|
|
|
39
40
|
);
|
|
40
41
|
`);
|
|
41
42
|
|
|
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);
|
|
49
|
+
|
|
50
|
+
if (!fleetColumns.includes('grp')) {
|
|
51
|
+
this.db.run('ALTER TABLE fleet ADD COLUMN grp TEXT');
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
if (legacyFleetPath !== undefined) {
|
|
43
55
|
this.adoptLegacyFleet(legacyFleetPath);
|
|
44
56
|
}
|
|
@@ -46,12 +58,23 @@ export class StateStore {
|
|
|
46
58
|
|
|
47
59
|
loadFleet(): FleetEntry[] {
|
|
48
60
|
const rows = this.db
|
|
49
|
-
.query<{ claude_id: string; name: string; cwd: string }, []>(
|
|
50
|
-
'SELECT claude_id, name, cwd FROM fleet',
|
|
61
|
+
.query<{ claude_id: string; name: string; cwd: string; grp: string | null }, []>(
|
|
62
|
+
'SELECT claude_id, name, cwd, grp FROM fleet',
|
|
51
63
|
)
|
|
52
64
|
.all();
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
const entries: FleetEntry[] = [];
|
|
67
|
+
|
|
68
|
+
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);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries;
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
writeFleet(entries: readonly FleetEntry[]): void {
|
|
@@ -59,11 +82,11 @@ export class StateStore {
|
|
|
59
82
|
this.db.run('DELETE FROM fleet');
|
|
60
83
|
|
|
61
84
|
const insert = this.db.query(
|
|
62
|
-
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd) VALUES (?1, ?2, ?3)',
|
|
85
|
+
'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, grp) VALUES (?1, ?2, ?3, ?4)',
|
|
63
86
|
);
|
|
64
87
|
|
|
65
88
|
for (const entry of all) {
|
|
66
|
-
insert.run(entry.claudeId, entry.name, entry.cwd);
|
|
89
|
+
insert.run(entry.claudeId, entry.name, entry.cwd, entry.group ?? null);
|
|
67
90
|
}
|
|
68
91
|
});
|
|
69
92
|
|
package/src/ui.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface StatusView {
|
|
|
66
66
|
readonly counts: Readonly<Record<SessionState, number>>;
|
|
67
67
|
readonly focusedName: string | null;
|
|
68
68
|
readonly urgentName: string | null;
|
|
69
|
+
readonly leaderLabel: string;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function drawStatusBar(view: StatusView) {
|
|
@@ -93,7 +94,7 @@ export function drawStatusBar(view: StatusView) {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
const joined = parts.join(' ▏');
|
|
96
|
-
const right = ` ${joined === '' ? 'idle' : joined}
|
|
97
|
+
const right = ` ${joined === '' ? 'idle' : joined} ▏${view.leaderLabel} `;
|
|
97
98
|
const pad = Math.max(1, width - left.length - right.length);
|
|
98
99
|
const bg = c.needs_you > 0 ? `${ESC}[1;97;41m` : `${ESC}[30;47m`;
|
|
99
100
|
const text = truncate(left + ' '.repeat(pad) + right, width).padEnd(width);
|
|
@@ -152,6 +153,7 @@ export interface OverlaySessionView extends SessionView {
|
|
|
152
153
|
readonly alive: boolean;
|
|
153
154
|
readonly kind: 'pty' | 'jsonl';
|
|
154
155
|
readonly resumable: boolean;
|
|
156
|
+
readonly group?: string;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
export interface OverlayView {
|
|
@@ -171,15 +173,29 @@ export function drawOverlay(view: OverlayView) {
|
|
|
171
173
|
rowsList.push(dimRow(width, empty));
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
// Sessions cluster under dim headers when more than one group exists: an
|
|
177
|
+
// explicit group wins, sessions without one fall back to their spawn
|
|
178
|
+
// directory. The rows keep the sorted order, so urgent groups lead.
|
|
179
|
+
const grouped = new Set(view.sessions.map((s) => s.group ?? s.cwd)).size > 1;
|
|
180
|
+
let lastKey: string | null = null;
|
|
181
|
+
|
|
174
182
|
for (const [i, s] of view.sessions.entries()) {
|
|
183
|
+
const key = s.group ?? s.cwd;
|
|
184
|
+
|
|
185
|
+
if (grouped && key !== lastKey) {
|
|
186
|
+
lastKey = key;
|
|
187
|
+
|
|
188
|
+
rowsList.push(dimRow(width, `▸ ${s.group ?? formatDir(s.cwd)}`));
|
|
189
|
+
}
|
|
190
|
+
|
|
175
191
|
const sel = i === view.selected;
|
|
176
192
|
const name = truncate(s.name, 16).padEnd(16);
|
|
177
193
|
const state = STATE_LABEL[s.state].padEnd(9);
|
|
178
|
-
const dir = truncate(formatDir(s.cwd), 18).padEnd(18)
|
|
179
|
-
const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - 19);
|
|
194
|
+
const dir = grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
|
|
195
|
+
const msgWidth = Math.max(4, width - 4 - 2 - 17 - 10 - (grouped ? 0 : 19));
|
|
180
196
|
const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
|
|
181
197
|
const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
|
|
182
|
-
const body = `${name} ${state}
|
|
198
|
+
const body = `${name} ${state}${dir} ${msg}`;
|
|
183
199
|
const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
|
|
184
200
|
|
|
185
201
|
rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${styledBody}`, 2 + body.length));
|
|
@@ -248,6 +264,7 @@ export function drawHelp() {
|
|
|
248
264
|
|
|
249
265
|
const lines = [
|
|
250
266
|
'⏎ attach the selected session',
|
|
267
|
+
'⇥ attach the most urgent needs-you session',
|
|
251
268
|
'a ack its notification without attaching',
|
|
252
269
|
'H eject to a headless run',
|
|
253
270
|
'P revive a dead or headless session',
|
|
@@ -317,7 +334,7 @@ export function drawPicker(view: PickerView) {
|
|
|
317
334
|
drawBox(rowsList);
|
|
318
335
|
}
|
|
319
336
|
|
|
320
|
-
export function drawHome(fleetCount = 0) {
|
|
337
|
+
export function drawHome(fleetCount = 0, leaderLabel = '^Space') {
|
|
321
338
|
out(ansi.clear + ansi.hideCursor);
|
|
322
339
|
|
|
323
340
|
const msgs = [
|
|
@@ -326,7 +343,7 @@ export function drawHome(fleetCount = 0) {
|
|
|
326
343
|
'n spawn a session',
|
|
327
344
|
'r adopt an existing session (claude --resume)',
|
|
328
345
|
...(fleetCount > 0 ? [`R restore last fleet (${fleetCount} sessions)`] : []),
|
|
329
|
-
|
|
346
|
+
`${leaderLabel.padEnd(7)} session list`,
|
|
330
347
|
'q quit',
|
|
331
348
|
];
|
|
332
349
|
|