@fresh-editor/fresh-editor 0.3.7 → 0.3.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.
@@ -76,6 +76,18 @@ interface AgentSession {
76
76
  state: AgentState;
77
77
  // Wall-clock ms when orchestrator.new fired createWindow.
78
78
  createdAt: number;
79
+ // `true` when this row is a worktree discovered on disk (via
80
+ // `git worktree list`) that has no live editor window yet.
81
+ // Discovered rows carry a synthetic negative `id`, no
82
+ // `terminalId`, and dive by *attaching* a new session to
83
+ // `root` rather than switching to an existing window. They are
84
+ // dropped from `orchestratorSessions` the moment a real window
85
+ // is opened at the same `root`.
86
+ discovered?: boolean;
87
+ // Branch checked out in this worktree (best-effort, for
88
+ // display). Set for discovered rows; left undefined for live
89
+ // sessions where the tab/label already carries the identity.
90
+ branch?: string;
79
91
  }
80
92
 
81
93
  // =============================================================================
@@ -84,26 +96,24 @@ interface AgentSession {
84
96
 
85
97
  const orchestratorSessions = new Map<number, AgentSession>();
86
98
 
87
- // Pending session-creation intent. Stashed across the
88
- // async `createWindow window_created hook` handoff so the
89
- // hook handler can attach the spawned terminal. (Internally
90
- // the editor calls these "windows"; Orchestrator still presents
91
- // them as "sessions" in its UX.)
92
- let pendingNewSession:
93
- | {
94
- label: string;
95
- branch: string;
96
- cmd: string;
97
- root: string;
98
- // Recorded for `setWindowState` after `window_created`.
99
- // `projectPath` is the canonical project root the user
100
- // pointed the form at; `sharedWorktree` is `true` when the
101
- // session shares its working tree (no dedicated `git
102
- // worktree add`).
103
- projectPath: string;
104
- sharedWorktree: boolean;
105
- }
106
- | null = null;
99
+ // Stable synthetic ids for discovered (on-disk, not-yet-opened)
100
+ // worktrees, keyed by canonical path. Live windows own the
101
+ // positive id space (editor `WindowId`s); discovered rows take
102
+ // negative ids so the two never collide and the existing
103
+ // `orchestratorSessions.get(id)` call sites keep working. Ids
104
+ // stay stable across rescans so the dialog selection doesn't
105
+ // jump when the worktree set is refreshed. `-1` is reserved as a
106
+ // "no selection" sentinel elsewhere, so allocation starts at `-2`.
107
+ const discoveredIdByPath = new Map<string, number>();
108
+ let nextDiscoveredId = -2;
109
+ function discoveredIdFor(path: string): number {
110
+ let id = discoveredIdByPath.get(path);
111
+ if (id === undefined) {
112
+ id = nextDiscoveredId--;
113
+ discoveredIdByPath.set(path, id);
114
+ }
115
+ return id;
116
+ }
107
117
 
108
118
  // New-session form state. `null` ⇒ the floating form isn't
109
119
  // open. Each field's `value` + `cursor` mirrors what the host
@@ -139,6 +149,14 @@ interface NewSessionForm {
139
149
  // (checkbox disabled, branch field inert). `null`: probe
140
150
  // in flight (keep checkbox in its last-known state).
141
151
  projectPathIsGit: boolean | null;
152
+ // `true` when the resolved Project Path is itself an existing
153
+ // *linked* worktree (created by `git worktree add`). In that
154
+ // case leaving "Create a new git worktree" unchecked attaches
155
+ // the session to it as a managed worktree rather than treating
156
+ // it as a shared root. The probe defaults the checkbox to
157
+ // unchecked when it first detects this, and `buildFormSpec`
158
+ // surfaces an explanatory hint. `null` while the probe runs.
159
+ projectPathIsLinkedWorktree: boolean | null;
142
160
  // Concrete session name the auto-generator would produce
143
161
  // for the current Project Path (e.g. "session-3"). Surfaced
144
162
  // as the Session Name placeholder so the user sees the
@@ -229,10 +247,34 @@ interface OpenDialogState {
229
247
  // anchor it needs.
230
248
  originalActiveSession: number;
231
249
  // When non-null, the preview pane swaps to a confirmation
232
- // panel for the named action against the named session id.
233
- // Cleared on Cancel or after the action completes.
250
+ // panel for the named action against the listed session ids.
251
+ // A single-element `ids` is the per-row Stop/Archive/Delete
252
+ // path; a multi-element `ids` is a bulk action over the
253
+ // checkbox selection. Cleared on Cancel or after the action
254
+ // completes.
234
255
  pendingConfirm:
235
- | { action: "stop" | "archive" | "delete"; sessionId: number }
256
+ | { action: "stop" | "archive" | "delete"; ids: number[] }
257
+ | null;
258
+ // Rows the user has checkbox-selected (Space, or click) for a
259
+ // bulk Stop/Archive/Delete. Holds session ids — positive for
260
+ // live windows, negative for discovered on-disk worktrees
261
+ // (which bulk-delete via `git worktree remove`). Survives filter
262
+ // and scope changes; pruned against the live set on every
263
+ // refresh. Bulk mode (the dedicated selection bar) engages once
264
+ // two or more rows are checked.
265
+ selectedIds: Set<number>;
266
+ // `true` shows the discovered on-disk worktree rows in the list.
267
+ // The "Show all worktrees" checkbox below the scope control toggles
268
+ // it (Alt+T / `orchestrator_toggle_worktrees`). Defaults to false
269
+ // (worktrees hidden) — discovery is opt-in. Remembered across opens
270
+ // via `lastShowWorktrees`.
271
+ showWorktrees: boolean;
272
+ // Progress marker for an in-flight *bulk* action. While set, the
273
+ // selection bar shows "Archiving 2/3…" and its buttons are
274
+ // hidden so a second Enter can't re-fire mid-batch. Cleared when
275
+ // the batch finishes.
276
+ bulkInFlight:
277
+ | { action: "stop" | "archive" | "delete"; total: number; done: number }
236
278
  | null;
237
279
  // Rows the embed reserves and rows the sessions list shows.
238
280
  // Captured once at dialog-open from the editor's viewport so
@@ -267,9 +309,30 @@ interface OpenDialogState {
267
309
  // too easy to skip over when the user's eyes are on the dialog.
268
310
  // Cleared on the next nav / filter change.
269
311
  lastError: string | null;
312
+ // Which sessions the list foregrounds:
313
+ // - "current": only sessions belonging to the active window's
314
+ // project (the default — launching in project B shouldn't
315
+ // bury you under project A's sessions). A trailing affordance
316
+ // row advertises how many sessions live in other projects.
317
+ // - "all": every session, across every project, each row
318
+ // labeled with its project so cross-project rows are obvious.
319
+ // Toggled with the scope key (⌥P by default). The filter input
320
+ // always searches globally regardless of scope, so typing a name
321
+ // from another project still surfaces it.
322
+ scope: "current" | "all";
270
323
  }
271
324
  let openDialog: OpenDialogState | null = null;
272
325
  let openPanel: FloatingWidgetPanel | null = null;
326
+ // Scope is remembered across opens of the picker (module state
327
+ // survives dialog close). Defaults to "all" so the picker opens
328
+ // showing every session; flipping it with the Project control / Alt+P
329
+ // updates this and the next open honours it.
330
+ let lastOpenScope: "current" | "all" = "all";
331
+ // Remembered across opens, like `lastOpenScope`: whether the
332
+ // discovered on-disk worktree rows are shown. Defaults to false
333
+ // (worktrees hidden) — surfacing them is opt-in via "Show all
334
+ // worktrees" (Alt+T).
335
+ let lastShowWorktrees = false;
273
336
  const OPEN_MODE = "orchestrator-open";
274
337
 
275
338
  // =============================================================================
@@ -303,9 +366,120 @@ function reconcileSessions(): void {
303
366
  if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
304
367
  }
305
368
  }
369
+ // Live windows live in the positive id space; their absence from
370
+ // `listWindows()` means they were closed, so drop them. Discovered
371
+ // worktrees (negative ids) are NOT backed by a window and must
372
+ // survive this sweep — they're pruned separately, against the
373
+ // on-disk worktree set, by `refreshDiscoveredWorktrees`.
306
374
  for (const id of orchestratorSessions.keys()) {
307
- if (!seen.has(id)) orchestratorSessions.delete(id);
375
+ if (id > 0 && !seen.has(id)) orchestratorSessions.delete(id);
376
+ }
377
+ // A worktree that's now open as a live window must not also linger
378
+ // as a discovered row. Drop any discovered entry whose root a live
379
+ // session already occupies.
380
+ const liveRoots = new Set<string>();
381
+ for (const s of orchestratorSessions.values()) {
382
+ if (!s.discovered) liveRoots.add(s.root);
383
+ }
384
+ for (const [id, s] of orchestratorSessions) {
385
+ if (s.discovered && liveRoots.has(s.root)) orchestratorSessions.delete(id);
386
+ }
387
+ }
388
+
389
+ // =============================================================================
390
+ // Discovered-worktree scan
391
+ //
392
+ // Surfaces worktrees that exist on disk but have no live editor
393
+ // window, so the user doesn't have to add them by hand. Because
394
+ // open sessions can span several repos, `git worktree list` must
395
+ // run once *per project*: the scan set is the distinct canonical
396
+ // repo roots of every live session, plus the editor's cwd repo.
397
+ // Each linked worktree not already open (and not an
398
+ // orchestrator-internal tree) becomes a discovered row that dives
399
+ // by attaching a fresh session to it.
400
+ // =============================================================================
401
+
402
+ let discoveryInFlight = false;
403
+
404
+ function isInternalWorktreePath(path: string): boolean {
405
+ // The sync-workspace and the `.archived/` graveyard are
406
+ // orchestrator bookkeeping, not user sessions.
407
+ return path.includes(".sync-workspace") || path.includes("/.archived/");
408
+ }
409
+
410
+ async function refreshDiscoveredWorktrees(): Promise<void> {
411
+ if (discoveryInFlight) return;
412
+ discoveryInFlight = true;
413
+ try {
414
+ reconcileSessions();
415
+
416
+ // (1) Candidate dirs: every live session's root + the editor
417
+ // cwd. Resolve each to its canonical main repo root and
418
+ // dedupe so a repo with N open worktrees is scanned once.
419
+ const candidates = new Set<string>([editor.getCwd()]);
420
+ for (const s of orchestratorSessions.values()) {
421
+ if (!s.discovered) candidates.add(s.root);
422
+ }
423
+ const mainRoots = new Set<string>();
424
+ for (const dir of candidates) {
425
+ const canonical = await resolveCanonicalRepoRoot(dir);
426
+ if (canonical) mainRoots.add(canonical);
427
+ }
428
+
429
+ // (2) Roots already occupied by a live session — discovered rows
430
+ // for these would be duplicates.
431
+ const liveRoots = new Set<string>();
432
+ for (const s of orchestratorSessions.values()) {
433
+ if (!s.discovered) liveRoots.add(s.root);
434
+ }
435
+
436
+ // (3) Scan each repo and collect the linked worktrees worth
437
+ // surfacing.
438
+ const foundPaths = new Set<string>();
439
+ for (const repoRoot of mainRoots) {
440
+ const listed = await listLinkedWorktrees(repoRoot);
441
+ if (!listed) continue;
442
+ for (const wt of listed.worktrees) {
443
+ if (liveRoots.has(wt.path)) continue;
444
+ if (isInternalWorktreePath(wt.path)) continue;
445
+ foundPaths.add(wt.path);
446
+ const id = discoveredIdFor(wt.path);
447
+ const label = wt.branch || editor.pathBasename(wt.path);
448
+ const existing = orchestratorSessions.get(id);
449
+ if (existing) {
450
+ existing.label = label;
451
+ existing.root = wt.path;
452
+ existing.projectPath = listed.mainRoot;
453
+ existing.branch = wt.branch;
454
+ } else {
455
+ orchestratorSessions.set(id, {
456
+ id,
457
+ label,
458
+ root: wt.path,
459
+ projectPath: listed.mainRoot,
460
+ sharedWorktree: false,
461
+ terminalId: null,
462
+ state: "ready",
463
+ createdAt: Date.now(),
464
+ discovered: true,
465
+ branch: wt.branch,
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ // (4) Prune discovered rows that vanished from disk (or got
472
+ // opened, picked up by the liveRoots check above).
473
+ for (const [id, s] of orchestratorSessions) {
474
+ if (s.discovered && !foundPaths.has(s.root)) {
475
+ orchestratorSessions.delete(id);
476
+ discoveredIdByPath.delete(s.root);
477
+ }
478
+ }
479
+ } finally {
480
+ discoveryInFlight = false;
308
481
  }
482
+ if (openPanel) refreshOpenDialog();
309
483
  }
310
484
 
311
485
  // =============================================================================
@@ -346,14 +520,90 @@ function ageString(createdAt: number): string {
346
520
  // The picker is cross-project by design — every session is a
347
521
  // candidate regardless of which project the active window
348
522
  // points at — so there is no project-scope filter here.
523
+ // Project a session belongs to, as a comparison key. Prefer the
524
+ // canonical `projectPath` recorded at create time; fall back to
525
+ // the session root for sessions that predate the field (the base
526
+ // session, externally-created windows).
527
+ function projectKeyOf(s: AgentSession): string {
528
+ return s.projectPath ?? s.root;
529
+ }
530
+
531
+ // The project the user is currently "in" — the active window's
532
+ // project. Falls back to the editor cwd when the active window
533
+ // isn't a tracked session (shouldn't normally happen, but keeps
534
+ // scoping well-defined).
535
+ function currentProjectKey(): string {
536
+ const s = orchestratorSessions.get(editor.activeWindow());
537
+ return s ? projectKeyOf(s) : editor.getCwd();
538
+ }
539
+
540
+ // Short, human-readable label for a project key — the trailing
541
+ // `parent/base` of the path, matching the new-session form's
542
+ // `deriveProjectLabel` style.
543
+ function projectLabel(key: string): string {
544
+ const base = editor.pathBasename(key);
545
+ const parent = editor.pathBasename(editor.pathDirname(key));
546
+ if (parent && parent !== base) return `${parent}/${base}`;
547
+ return base || key;
548
+ }
549
+
550
+ // Resolve the id list for the current filter + scope.
551
+ //
552
+ // Scope only constrains the *empty-filter* view: with no needle
553
+ // and `scope === "current"`, the list shows just the active
554
+ // project's sessions (current project first, by id). As soon as
555
+ // the user types, the search goes global regardless of scope —
556
+ // hiding a session the user is explicitly searching for would be
557
+ // the worse surprise. `scope === "all"` always shows everything,
558
+ // sorted by project (current project first) so rows are grouped
559
+ // rather than interleaved.
349
560
  function filterSessions(needle: string): number[] {
350
561
  reconcileSessions();
351
- const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
352
- if (!needle) return ids;
562
+ const scope = openDialog?.scope ?? "current";
563
+ const showWorktrees = openDialog?.showWorktrees ?? false;
564
+ const cur = currentProjectKey();
565
+ let allIds = Array.from(orchestratorSessions.keys());
566
+ // "Show all worktrees" is opt-in: by default the discovered on-disk
567
+ // worktree rows are filtered out.
568
+ if (!showWorktrees) {
569
+ allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
570
+ }
571
+
572
+ const isDisc = (id: number): number =>
573
+ orchestratorSessions.get(id)!.discovered ? 1 : 0;
574
+
575
+ // Sort by (current-project-first, project, live-before-discovered,
576
+ // then id) so an "all" view groups the current project's sessions
577
+ // at the top and other projects' below, and within each project the
578
+ // pre-existing live sessions come first with the discovered on-disk
579
+ // worktrees listed after them.
580
+ const byProjectThenId = (a: number, b: number): number => {
581
+ const sa = orchestratorSessions.get(a)!;
582
+ const sb = orchestratorSessions.get(b)!;
583
+ const aCur = projectKeyOf(sa) === cur ? 0 : 1;
584
+ const bCur = projectKeyOf(sb) === cur ? 0 : 1;
585
+ if (aCur !== bCur) return aCur - bCur;
586
+ const ka = projectKeyOf(sa);
587
+ const kb = projectKeyOf(sb);
588
+ if (ka !== kb) return ka < kb ? -1 : 1;
589
+ const da = isDisc(a);
590
+ const db = isDisc(b);
591
+ if (da !== db) return da - db;
592
+ return a - b;
593
+ };
594
+
595
+ if (!needle) {
596
+ const ids = allIds.slice().sort(byProjectThenId);
597
+ if (scope === "current") {
598
+ return ids.filter((id) => projectKeyOf(orchestratorSessions.get(id)!) === cur);
599
+ }
600
+ return ids;
601
+ }
602
+
353
603
  const n = needle.toLowerCase();
354
604
  type Scored = { id: number; score: number; len: number };
355
605
  const matches: Scored[] = [];
356
- for (const id of ids) {
606
+ for (const id of allIds) {
357
607
  const s = orchestratorSessions.get(id)!;
358
608
  const label = s.label.toLowerCase();
359
609
  const root = s.root.toLowerCase();
@@ -365,53 +615,105 @@ function filterSessions(needle: string): number[] {
365
615
  matches.push({ id, score: 2, len: label.length });
366
616
  }
367
617
  }
368
- matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
618
+ // Live sessions before discovered worktrees at equal relevance, so
619
+ // the on-disk rows still trail the real sessions in search results.
620
+ matches.sort(
621
+ (a, b) =>
622
+ a.score - b.score || isDisc(a.id) - isDisc(b.id) || a.len - b.len ||
623
+ a.id - b.id,
624
+ );
369
625
  return matches.map((m) => m.id);
370
626
  }
371
627
 
372
- // Build one rendered list-item row for `id`. Style cues:
373
- // * `[id]` in `ui.help_key_fg`
374
- // * `ACT` (active session) in `ui.tab_active_fg` + bold
375
- // * other states use the default fg
376
- // * label in default fg
628
+ // Width of the NAME column before the trailing PROJECT column kicks
629
+ // in (filled only for cross-project rows). Kept in sync with
630
+ // `sessionsColumnHeader`. There is no id column — the numeric window
631
+ // id is an internal handle the user never needs in the list; rows are
632
+ // identified by name, the active one rendered bold and on-disk
633
+ // worktrees flagged with a `· on-disk` tag.
634
+ const LIST_NAME_W = 24;
635
+
636
+ // Header row above the session list: `NAME … PROJECT`.
637
+ function sessionsColumnHeader(): WidgetSpec {
638
+ return {
639
+ kind: "raw",
640
+ entries: [
641
+ styledRow([
642
+ {
643
+ // 4-space lead aligns under the per-row `[ ] ` checkbox.
644
+ text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
645
+ style: { fg: "ui.menu_disabled_fg" },
646
+ },
647
+ ]),
648
+ ],
649
+ };
650
+ }
651
+
652
+ // Build one rendered list-item row for `id`:
653
+ // `[ ] ` <name + BASE/⇄ badges + on-disk tag> <project basename>
654
+ // The active session's name renders bold; discovered (on-disk,
655
+ // unopened) worktrees render dim with a `· on-disk` tag instead of a
656
+ // glyph. The project column is filled only for sessions that don't
657
+ // belong to the current project.
377
658
  function renderListItem(id: number, activeId: number): TextPropertyEntry {
378
659
  const s = orchestratorSessions.get(id);
379
660
  if (!s) {
380
- return styledRow([{ text: `[${id}] (unknown)` }]);
661
+ return styledRow([{ text: "(unknown)" }]);
381
662
  }
382
663
  const isActive = id === activeId;
383
664
  const isBase = id === 1;
384
- const stateText = isActive ? "ACT " : STATE_GLYPH[s.state];
665
+ const isDiscovered = !!s.discovered;
666
+ const isChecked = openDialog?.selectedIds.has(id) ?? false;
667
+
668
+ // Leading multi-select checkbox. `[x]` when this row is in the
669
+ // bulk selection, `[ ]` otherwise — toggled with Space (the
670
+ // rebindable `orchestrator_toggle_select`) or a click.
671
+ const checkbox = {
672
+ text: isChecked ? "[x] " : "[ ] ",
673
+ style: isChecked
674
+ ? { fg: "ui.help_key_fg", bold: true }
675
+ : { fg: "ui.menu_disabled_fg" },
676
+ };
677
+
385
678
  const entries: { text: string; style?: Record<string, unknown> }[] = [
386
- { text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
679
+ checkbox,
387
680
  {
388
- text: stateText,
681
+ text: s.label,
389
682
  style: isActive
390
- ? { fg: "ui.tab_active_fg", bold: true }
391
- : { fg: "ui.menu_disabled_fg" },
683
+ ? { fg: "ui.help_key_fg", bold: true }
684
+ : isDiscovered
685
+ ? { fg: "ui.menu_disabled_fg" }
686
+ : undefined,
392
687
  },
393
- { text: ` ${s.label}` },
394
688
  ];
395
- // BASE badge: the base session is the editor process itself
396
- // archive / delete would close the editor (and possibly destroy
397
- // the user's current worktree), so the host refuses both. The
398
- // badge makes that special status visible up-front instead of
399
- // surfacing as "Archive disabled, why?" after a Tab.
689
+ // Visible width of the NAME column so far (label + badges), used
690
+ // to pad out to LIST_NAME_W before the PROJECT column.
691
+ let nameWidth = s.label.length;
400
692
  if (isBase) {
693
+ entries.push({ text: " BASE", style: { fg: "ui.help_key_fg", bold: true } });
694
+ nameWidth += 5;
695
+ }
696
+ if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
697
+ entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
698
+ nameWidth += 2;
699
+ }
700
+ if (isDiscovered) {
401
701
  entries.push({
402
- text: " BASE",
403
- style: { fg: "ui.help_key_fg", bold: true },
702
+ text: " · on-disk",
703
+ style: { fg: "ui.menu_disabled_fg", italic: true },
404
704
  });
705
+ nameWidth += 10;
405
706
  }
406
- // SHARED badge for the row when this session shares its
407
- // worktree (with another session or with the project root
408
- // directly). Mirrors the preview pane's badge so the
409
- // shared-worktree status is visible at-a-glance from the
410
- // list too.
411
- if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
707
+ // PROJECT column: basename for cross-project rows only; current-
708
+ // project rows leave it blank (the whole list is one project when
709
+ // scoped, so this column is empty then).
710
+ const proj = projectKeyOf(s);
711
+ if (proj !== currentProjectKey()) {
712
+ const pad = Math.max(1, LIST_NAME_W - nameWidth);
713
+ entries.push({ text: " ".repeat(pad) });
412
714
  entries.push({
413
- text: " ⇄",
414
- style: { fg: "ui.menu_disabled_fg" },
715
+ text: editor.pathBasename(proj),
716
+ style: { fg: "ui.menu_disabled_fg", italic: true },
415
717
  });
416
718
  }
417
719
  return styledRow(entries as Parameters<typeof styledRow>[0]);
@@ -446,7 +748,7 @@ function buildPreviewEntries(
446
748
  {
447
749
  text: stateText,
448
750
  style: isActive
449
- ? { fg: "ui.tab_active_fg", bold: true }
751
+ ? { fg: "ui.help_key_fg", bold: true }
450
752
  : { fg: "ui.menu_disabled_fg" },
451
753
  },
452
754
  { text: " " },
@@ -508,26 +810,98 @@ function countSiblingsAtRoot(root: string): number {
508
810
  return n;
509
811
  }
510
812
 
813
+ // =============================================================================
814
+ // Multi-select / bulk actions
815
+ //
816
+ // The user checkbox-selects rows (Space — the rebindable
817
+ // `orchestrator_toggle_select` — or a click). Once two or more rows
818
+ // are checked the preview pane swaps to the bulk selection bar
819
+ // (`buildBulkPane`) offering Stop / Archive / Delete over the whole
820
+ // set, with a single confirmation for the batch. Rows ineligible for
821
+ // a given action (the base session; live sessions sharing a worktree)
822
+ // are skipped, and each button's count reflects only the eligible
823
+ // members.
824
+ // =============================================================================
825
+
826
+ type BulkAction = "stop" | "archive" | "delete";
827
+
828
+ // Checked ids that still resolve to a known session, in the dialog's
829
+ // current display order (so the bulk bar lists them the way the list
830
+ // shows them). Selection persists across filter/scope changes, so an
831
+ // id can be checked while filtered out of view — those still count.
832
+ function selectedSessions(): number[] {
833
+ if (!openDialog) return [];
834
+ const order = openDialog.filteredIds;
835
+ const seen = new Set<number>();
836
+ const out: number[] = [];
837
+ for (const id of order) {
838
+ if (openDialog.selectedIds.has(id) && orchestratorSessions.has(id)) {
839
+ out.push(id);
840
+ seen.add(id);
841
+ }
842
+ }
843
+ // Checked-but-filtered-out rows, appended in id order so the count
844
+ // stays honest even when a search hides part of the selection.
845
+ for (const id of openDialog.selectedIds) {
846
+ if (!seen.has(id) && orchestratorSessions.has(id)) out.push(id);
847
+ }
848
+ return out;
849
+ }
850
+
851
+ // Is `id` a legal target for `action`? Base session is never
852
+ // touched. Stop only applies to live windows. Archive/Delete apply
853
+ // to discovered worktrees (removable on disk) and to live sessions
854
+ // that own their worktree outright (not shared with siblings or the
855
+ // project root).
856
+ function bulkEligible(action: BulkAction, id: number): boolean {
857
+ const s = orchestratorSessions.get(id);
858
+ if (!s) return false;
859
+ if (id === 1) return false;
860
+ if (action === "stop") return !s.discovered && id > 0;
861
+ if (s.discovered) return true;
862
+ const sharesRoot = countSiblingsAtRoot(s.root) > 1 || s.sharedWorktree;
863
+ return !sharesRoot;
864
+ }
865
+
866
+ function eligibleSelected(action: BulkAction): number[] {
867
+ return selectedSessions().filter((id) => bulkEligible(action, id));
868
+ }
869
+
870
+ // Drop checked ids whose session has vanished (closed window,
871
+ // pruned worktree) so the selection can't grow stale references.
872
+ function pruneSelection(): void {
873
+ if (!openDialog) return;
874
+ for (const id of [...openDialog.selectedIds]) {
875
+ if (!orchestratorSessions.has(id)) openDialog.selectedIds.delete(id);
876
+ }
877
+ }
878
+
511
879
  // Blank-row separator used inside the Sessions column between
512
880
  // the filter, the new-session button, and the list.
513
881
  function sessionsSeparator(): WidgetSpec {
514
882
  return spacer(0);
515
883
  }
516
884
 
517
- // Approximate number of session rows the picker's list pane
518
- // should show. Sized off the full terminal (not the active
519
- // buffer's viewport that shrinks with vertical splits and made
520
- // the picker collapse to ~half its `heightPct: 90` budget).
521
- function openListVisibleRows(): number {
885
+ // Smallest list height we'll show even when there are only a
886
+ // couple of sessions keeps the preview pane (which matches the
887
+ // list height) usable rather than collapsing to a sliver.
888
+ const MIN_LIST_ROWS = 6;
889
+
890
+ // Upper bound on session rows for this terminal — the list height
891
+ // when the panel is at its full `heightPct: 90` budget. Sized off
892
+ // the full terminal (not the active buffer's viewport — that
893
+ // shrinks with vertical splits and made the picker collapse to
894
+ // ~half its budget).
895
+ function maxListRowsForScreen(): number {
522
896
  const screen = editor.getScreenSize();
523
897
  const h = screen.height > 0 ? screen.height : 30;
524
898
  const panelH = Math.floor(h * 0.9);
525
- // panel borders (2) + header (1) + spacer (1) + sessions
526
- // section borders (2) + filter row (1) + separator (1) +
527
- // new-session button row (1) + separator (1) + footer (1) =
528
- // 11 rows of chrome. Floor at 4 so a tiny terminal still
529
- // shows something.
530
- return Math.max(4, panelH - 11);
899
+ // Chrome that isn't list rows: panel borders (2) + title (1) +
900
+ // spacer (1) + footer (1) + sessions-section borders (2) +
901
+ // column chrome above the list (New + Project + Worktree-filter +
902
+ // Filter + separator + header = 6) = 13. Floor at MIN_LIST_ROWS so
903
+ // a tiny terminal still shows something.
904
+ return Math.max(MIN_LIST_ROWS, panelH - 13);
531
905
  }
532
906
 
533
907
  // Compose the right-hand preview pane. Normally it shows info
@@ -572,130 +946,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
572
946
  ),
573
947
  });
574
948
  }
575
- if (openDialog?.pendingConfirm && s && openDialog.pendingConfirm.sessionId === s.id) {
576
- const action = openDialog.pendingConfirm.action;
577
- if (action === "stop") {
578
- return labeledSection({
579
- label: "Confirm Stop",
580
- child: col(
581
- {
582
- kind: "raw",
583
- entries: [
584
- styledRow([
585
- {
586
- text: `Stop session [${s.id}] ${s.label}?`,
587
- style: { bold: true },
588
- },
589
- ]),
590
- styledRow([{ text: "" }]),
591
- styledRow([{ text: "This will:" }]),
592
- styledRow([{ text: " • send SIGTERM to all session processes" }]),
593
- styledRow([{ text: " • SIGKILL after a short grace period" }]),
594
- styledRow([{ text: "" }]),
595
- styledRow([{ text: "The worktree and session record remain." }]),
596
- ],
597
- },
598
- spacer(0),
599
- row(
600
- flexSpacer(),
601
- button("Cancel", { key: "confirm-cancel" }),
602
- spacer(2),
603
- button("Confirm Stop", {
604
- intent: "danger",
605
- key: "confirm-stop",
606
- }),
607
- ),
608
- ),
609
- });
610
- }
611
- if (action === "archive") {
612
- return labeledSection({
613
- label: "Confirm Archive",
614
- child: col(
615
- {
616
- kind: "raw",
617
- entries: [
618
- styledRow([
619
- {
620
- text: `Archive session [${s.id}] ${s.label}?`,
621
- style: { bold: true },
622
- },
623
- ]),
624
- styledRow([{ text: "" }]),
625
- styledRow([{ text: "This will:" }]),
626
- styledRow([{ text: " • SIGKILL all session processes" }]),
627
- styledRow([{ text: " • close the editor session" }]),
628
- styledRow([{ text: " • move the worktree to .archived/" }]),
629
- styledRow([{ text: "" }]),
630
- styledRow([{ text: "Reversible via Unarchive." }]),
631
- ],
632
- },
633
- spacer(0),
634
- row(
635
- flexSpacer(),
636
- button("Cancel", { key: "confirm-cancel" }),
637
- spacer(2),
638
- button("Confirm Archive", {
639
- intent: "danger",
640
- key: "confirm-archive",
641
- }),
642
- ),
643
- ),
644
- });
645
- }
646
- if (action === "delete") {
647
- return labeledSection({
648
- label: "Confirm Delete",
649
- child: col(
650
- {
651
- kind: "raw",
652
- entries: [
653
- styledRow([
654
- {
655
- text: `Delete session [${s.id}] ${s.label}?`,
656
- style: { bold: true },
657
- },
658
- ]),
659
- styledRow([{ text: "" }]),
660
- styledRow([{ text: "This will:" }]),
661
- styledRow([{ text: " • stop all session processes" }]),
662
- styledRow([{ text: " • run `git worktree remove`" }]),
663
- styledRow([{ text: " • drop the session record" }]),
664
- styledRow([{ text: "" }]),
665
- styledRow([
666
- {
667
- text: "Uncommitted changes will be lost.",
668
- style: {
669
- fg: "ui.status_error_indicator_fg",
670
- bold: true,
671
- },
672
- },
673
- ]),
674
- ],
675
- },
676
- spacer(0),
677
- row(
678
- flexSpacer(),
679
- button("Cancel", { key: "confirm-cancel" }),
680
- spacer(2),
681
- button("Confirm Delete", {
682
- intent: "danger",
683
- key: "confirm-delete",
684
- }),
685
- ),
686
- ),
687
- });
688
- }
949
+ // Confirmation panel single-row Stop/Archive/Delete or a bulk
950
+ // batch. Independent of the cursor row: the confirmed ids live in
951
+ // `pendingConfirm`, so it renders whenever a confirm is pending.
952
+ if (openDialog?.pendingConfirm) {
953
+ return buildConfirmPane(openDialog.pendingConfirm);
954
+ }
955
+ // Bulk selection bar: two or more rows checked (or a bulk action
956
+ // in flight) → operate on the whole batch rather than the cursor
957
+ // row.
958
+ if (selectedSessions().length >= 2 || openDialog?.bulkInFlight) {
959
+ return buildBulkPane();
689
960
  }
690
961
  // Match the sessions column's content height so the two panes'
691
- // bottom borders land on the same row. Sessions column inside
692
- // its borders = filter (1) + separator (1) + new-session button
693
- // (1) + separator (1) + list (listVisibleRows) = listVisibleRows
694
- // + 4. Preview inside its borders = button row (1) + spacer (1)
695
- // + embedRows, so embedRows must equal listVisibleRows + 2.
696
- // When details ARE shown, two info rows + a spacer eat three
697
- // more lines — `_DETAILS_CHROME_ROWS` accounts for that.
698
- const totalEmbedBase = (openDialog?.listVisibleRows ?? 6) + 2;
962
+ // bottom borders land on the same row. Sessions column inside its
963
+ // borders = New (1) + Project (1) + Worktree-filter (1) +
964
+ // Filter (1) + separator (1) + header (1) + list (listVisibleRows)
965
+ // = listVisibleRows + 6. Preview inside its borders = button
966
+ // row (1) + spacer (1) + embedRows, so embedRows must equal
967
+ // listVisibleRows + 4. When details ARE shown, two info rows + a
968
+ // spacer eat three more lines — `_DETAILS_CHROME_ROWS` accounts
969
+ // for that.
970
+ const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4;
699
971
  const detailsOn = openDialog?.showDetails ?? false;
700
972
  const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
701
973
  const embedRows = Math.max(
@@ -728,6 +1000,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
728
1000
  // turns details on, pressing `[ Preview ]` turns them off
729
1001
  // (back to compact).
730
1002
  const detailsToggleLabel = detailsOn ? "Preview" : "Details";
1003
+ // Discovered worktree: no live window to embed, so there's
1004
+ // nothing to Stop / Archive / Delete yet. Offer only "Open"
1005
+ // (Visit attaches a fresh session to the worktree) and describe
1006
+ // what diving will do. The empty `windowId: 0` embed keeps the
1007
+ // pane the same height as live-session previews so the dialog
1008
+ // doesn't jump when the selection moves between row kinds.
1009
+ if (s.discovered) {
1010
+ const openButtonRow = row(
1011
+ button("Open", { intent: "primary", key: "visit" }),
1012
+ flexSpacer(),
1013
+ button("Stop", { key: "stop", disabled: true }),
1014
+ spacer(2),
1015
+ button("Archive", { key: "archive", disabled: true }),
1016
+ spacer(2),
1017
+ button("Delete", { intent: "danger", key: "delete", disabled: true }),
1018
+ );
1019
+ const info: TextPropertyEntry[] = [
1020
+ styledRow([
1021
+ { text: "On-disk worktree (not open)", style: { fg: "ui.menu_disabled_fg", bold: true } },
1022
+ ]),
1023
+ styledRow([{ text: "" }]),
1024
+ styledRow([{ text: "branch ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.branch || "(detached)" }]),
1025
+ styledRow([{ text: "path ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.root }]),
1026
+ styledRow([{ text: "" }]),
1027
+ styledRow([
1028
+ {
1029
+ text: "Press Enter to open this worktree as a session.",
1030
+ style: { fg: "ui.help_key_fg", italic: true },
1031
+ },
1032
+ ]),
1033
+ ];
1034
+ return labeledSection({
1035
+ label: `${s.label} — on-disk worktree`,
1036
+ child: col(
1037
+ openButtonRow,
1038
+ spacer(0),
1039
+ { kind: "raw", entries: info },
1040
+ spacer(0),
1041
+ windowEmbed({ windowId: 0, rows: Math.max(3, embedRows - 6), key: "live-preview" }),
1042
+ ),
1043
+ });
1044
+ }
731
1045
  // Per-action availability. The row always renders all four
732
1046
  // buttons (no layout shift between selections), but each is
733
1047
  // marked disabled when its action would be refused against the
@@ -783,43 +1097,243 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
783
1097
  // its worktree would close the editor / break the user's current
784
1098
  // tree, so Stop / Archive / Delete refuse against it.
785
1099
  const sectionLabel = isBase
786
- ? `[${s.id}] ${s.label} BASE editor session`
787
- : `[${s.id}] ${s.label}`;
1100
+ ? `${s.label} BASE (editor session)`
1101
+ : s.label;
788
1102
  return labeledSection({
789
1103
  label: sectionLabel,
790
1104
  child: body,
791
1105
  });
792
1106
  }
793
1107
 
1108
+ // The per-action bullet lines shown in the confirmation panel.
1109
+ // `delete` adds a separate red "uncommitted changes" line in the
1110
+ // caller because it needs distinct styling.
1111
+ function confirmActionLines(action: BulkAction): string[] {
1112
+ switch (action) {
1113
+ case "stop":
1114
+ return [
1115
+ " • send SIGTERM to all session processes",
1116
+ " • SIGKILL after a short grace period",
1117
+ "",
1118
+ "The worktree and session record remain.",
1119
+ ];
1120
+ case "archive":
1121
+ return [
1122
+ " • SIGKILL all session processes",
1123
+ " • close the editor session",
1124
+ " • move the worktree to .archived/",
1125
+ "",
1126
+ "Reversible via Unarchive.",
1127
+ ];
1128
+ case "delete":
1129
+ return [
1130
+ " • stop all session processes",
1131
+ " • run `git worktree remove`",
1132
+ " • drop the session record",
1133
+ ];
1134
+ }
1135
+ }
1136
+
1137
+ // Confirmation panel for a Stop/Archive/Delete over one or many
1138
+ // sessions. A single id renders the familiar per-session prompt; two
1139
+ // or more render a batch prompt that lists the targets. The Confirm
1140
+ // button reuses the same `confirm-<action>` key the single path
1141
+ // always used, so the existing widget_event handlers fire for both —
1142
+ // they read `pendingConfirm.ids`.
1143
+ function buildConfirmPane(
1144
+ confirm: { action: BulkAction; ids: number[] },
1145
+ ): WidgetSpec {
1146
+ const { action, ids } = confirm;
1147
+ const cap = action[0].toUpperCase() + action.slice(1);
1148
+ const existing = ids.filter((id) => orchestratorSessions.has(id));
1149
+ const bulk = existing.length > 1;
1150
+ const diskNote = (id: number): string =>
1151
+ orchestratorSessions.get(id)?.discovered ? " · on-disk" : "";
1152
+ const entries: TextPropertyEntry[] = [];
1153
+ if (bulk) {
1154
+ entries.push(
1155
+ styledRow([
1156
+ { text: `${cap} these ${existing.length} sessions?`, style: { bold: true } },
1157
+ ]),
1158
+ styledRow([{ text: "" }]),
1159
+ );
1160
+ for (const id of existing.slice(0, 8)) {
1161
+ const ss = orchestratorSessions.get(id)!;
1162
+ entries.push(
1163
+ styledRow([
1164
+ { text: ` ${ss.label}` },
1165
+ { text: diskNote(id), style: { fg: "ui.menu_disabled_fg", italic: true } },
1166
+ ]),
1167
+ );
1168
+ }
1169
+ if (existing.length > 8) {
1170
+ entries.push(
1171
+ styledRow([
1172
+ {
1173
+ text: ` … and ${existing.length - 8} more`,
1174
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1175
+ },
1176
+ ]),
1177
+ );
1178
+ }
1179
+ } else {
1180
+ const id = existing[0];
1181
+ const ss = id !== undefined ? orchestratorSessions.get(id) : undefined;
1182
+ entries.push(
1183
+ styledRow([
1184
+ { text: `${cap} session ${ss?.label ?? ""}?`, style: { bold: true } },
1185
+ ]),
1186
+ );
1187
+ }
1188
+ entries.push(
1189
+ styledRow([{ text: "" }]),
1190
+ styledRow([{ text: bulk ? "For each session this will:" : "This will:" }]),
1191
+ );
1192
+ for (const line of confirmActionLines(action)) {
1193
+ entries.push(styledRow([{ text: line }]));
1194
+ }
1195
+ if (action === "delete") {
1196
+ entries.push(
1197
+ styledRow([{ text: "" }]),
1198
+ styledRow([
1199
+ {
1200
+ text: "Uncommitted changes will be lost.",
1201
+ style: { fg: "ui.status_error_indicator_fg", bold: true },
1202
+ },
1203
+ ]),
1204
+ );
1205
+ }
1206
+ return labeledSection({
1207
+ label: bulk ? `Confirm ${cap} — ${existing.length} sessions` : `Confirm ${cap}`,
1208
+ child: col(
1209
+ { kind: "raw", entries },
1210
+ spacer(0),
1211
+ row(
1212
+ flexSpacer(),
1213
+ button("Cancel", { key: "confirm-cancel" }),
1214
+ spacer(2),
1215
+ button(`Confirm ${cap}`, { intent: "danger", key: `confirm-${action}` }),
1216
+ ),
1217
+ ),
1218
+ });
1219
+ }
1220
+
1221
+ // The dedicated bulk selection bar (Layout B). Shown in place of the
1222
+ // per-session preview when two or more rows are checked. The bulk
1223
+ // action buttons sit at the *top* of the pane; the list of affected
1224
+ // sessions renders below as a scrollable `list` widget (so a long
1225
+ // selection scrolls — keyboard, wheel, and the draggable scrollbar —
1226
+ // rather than overflowing the pane). Each action's count is the
1227
+ // number of *eligible* members; an action with no eligible members is
1228
+ // disabled.
1229
+ function buildBulkPane(): WidgetSpec {
1230
+ const sel = selectedSessions();
1231
+ const stopN = eligibleSelected("stop").length;
1232
+ const archiveN = eligibleSelected("archive").length;
1233
+ const deleteN = eligibleSelected("delete").length;
1234
+
1235
+ const inflight = openDialog?.bulkInFlight ?? null;
1236
+ const actionRow = inflight
1237
+ ? row(
1238
+ {
1239
+ kind: "raw",
1240
+ entries: [
1241
+ styledRow([
1242
+ {
1243
+ text: `${inflight.action[0].toUpperCase()}${inflight.action.slice(1)}ing ${inflight.done}/${inflight.total}…`,
1244
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1245
+ },
1246
+ ]),
1247
+ ],
1248
+ },
1249
+ flexSpacer(),
1250
+ )
1251
+ : row(
1252
+ button(`Stop (${stopN})`, { key: "bulk-stop", disabled: stopN === 0 }),
1253
+ spacer(2),
1254
+ button(`Archive (${archiveN})`, {
1255
+ key: "bulk-archive",
1256
+ disabled: archiveN === 0,
1257
+ }),
1258
+ spacer(2),
1259
+ button(`Delete (${deleteN})`, {
1260
+ intent: "danger",
1261
+ key: "bulk-delete",
1262
+ disabled: deleteN === 0,
1263
+ }),
1264
+ flexSpacer(),
1265
+ button("Clear", { key: "bulk-clear" }),
1266
+ );
1267
+
1268
+ // Affected-sessions list. Flag the rows a destructive action will
1269
+ // skip so the count discrepancy explains itself.
1270
+ const items: TextPropertyEntry[] = sel.map((id) => {
1271
+ const ss = orchestratorSessions.get(id)!;
1272
+ const rowParts: StyledSegment[] = [{ text: ` ${ss.label}` }];
1273
+ if (id === 1) {
1274
+ rowParts.push({
1275
+ text: " · base (protected)",
1276
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1277
+ });
1278
+ } else if (!ss.discovered && (countSiblingsAtRoot(ss.root) > 1 || ss.sharedWorktree)) {
1279
+ rowParts.push({
1280
+ text: " · shared worktree",
1281
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1282
+ });
1283
+ } else if (ss.discovered) {
1284
+ rowParts.push({
1285
+ text: " · on-disk worktree",
1286
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1287
+ });
1288
+ }
1289
+ return styledRow(rowParts);
1290
+ });
1291
+ const itemKeys = sel.map((id) => `bulksel-${id}`);
1292
+ // Match the preview pane's height: content = action row (1) +
1293
+ // spacer (1) + list, and the embed pane reserves `listVisibleRows
1294
+ // + 4` for its body — so the list takes that height and the two
1295
+ // panes' bottom borders line up.
1296
+ const listRows = Math.max(3, (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4);
1297
+
1298
+ return labeledSection({
1299
+ label: `Bulk actions — ${sel.length} selected`,
1300
+ child: col(
1301
+ actionRow,
1302
+ spacer(0),
1303
+ list({
1304
+ items,
1305
+ itemKeys,
1306
+ // Display-only: no highlighted row, and out of the Tab cycle
1307
+ // (focus belongs on the action buttons). Up/Down still scroll
1308
+ // it via the host's smart-key forwarding, and the scrollbar
1309
+ // drags it.
1310
+ selectedIndex: -1,
1311
+ visibleRows: listRows,
1312
+ focusable: false,
1313
+ key: "bulk-list",
1314
+ }),
1315
+ ),
1316
+ });
1317
+ }
1318
+
794
1319
  function buildOpenSpec(): WidgetSpec {
795
1320
  if (!openDialog) return col();
796
- // Re-derive row counts on every spec build as a fallback for the
797
- // resize hook not always firing reliably through tmux's SIGWINCH
798
- // propagation (Finding I). **One-way ratchet**: only adopt the
799
- // new value when it's *larger* than the current one. The
800
- // `editor.getViewport()` height shrinks while the picker is
801
- // mounted (the floating panel covers part of the buffer area),
802
- // and naively re-reading it on every refresh fed that shrink
803
- // back into the dialog size — pressing Up/Down caused the
804
- // picker to oscillate smaller on every keystroke. A real
805
- // terminal-grow event still flows through because the new
806
- // viewport height exceeds the cached value; a spurious shrink
807
- // (because the panel itself is up) is ignored.
808
- const liveListVisibleRows = openListVisibleRows();
809
- if (liveListVisibleRows > openDialog.listVisibleRows) {
810
- openDialog.listVisibleRows = liveListVisibleRows;
811
- openDialog.embedRows = Math.max(3, liveListVisibleRows - 5);
812
- }
813
1321
  const filtered = openDialog.filteredIds;
1322
+ // Fill the panel's full height budget (the list pads with blank
1323
+ // rows when there are few sessions) so the dialog stays
1324
+ // vertically full rather than collapsing to a short floating box.
1325
+ openDialog.listVisibleRows = maxListRowsForScreen();
814
1326
  const activeId = editor.activeWindow();
815
1327
  const items = filtered.map((id) => renderListItem(id, activeId));
816
1328
  const itemKeys = filtered.map(String);
817
1329
  const selIdx = filtered.length === 0
818
1330
  ? -1
819
1331
  : Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
820
- const selectedId = selIdx >= 0 ? filtered[selIdx] : -1;
821
- const selectedSession = selectedId > 0
822
- ? orchestratorSessions.get(selectedId)
1332
+ // Gate on the *index* (selIdx < 0 means "filter matched nothing"),
1333
+ // not the sign of the id: discovered worktrees carry negative ids
1334
+ // and must still resolve to their row here.
1335
+ const selectedSession = selIdx >= 0
1336
+ ? orchestratorSessions.get(filtered[selIdx])
823
1337
  : undefined;
824
1338
 
825
1339
  // The "New Session" button advertises Alt+N (or whatever the
@@ -838,9 +1352,7 @@ function buildOpenSpec(): WidgetSpec {
838
1352
  "orchestrator_open_new_from_picker",
839
1353
  OPEN_MODE,
840
1354
  );
841
- const newLabel = newKey
842
- ? `+ New Session ${newKey}`
843
- : "+ New Session";
1355
+ const newLabel = newKey ? `+ New ${newKey}` : "+ New";
844
1356
  const inConfirm = openDialog.pendingConfirm !== null;
845
1357
  // While a confirmation prompt is up the filter is rendered
846
1358
  // without a `key`. The host's `collect_tabbable` only adds
@@ -853,7 +1365,8 @@ function buildOpenSpec(): WidgetSpec {
853
1365
  const filterInput = text({
854
1366
  value: openDialog.filter.value,
855
1367
  cursorByte: openDialog.filter.cursor,
856
- placeholder: "type to filter…",
1368
+ label: "Filter",
1369
+ placeholder: "type to search… ( / )",
857
1370
  fullWidth: true,
858
1371
  key: inConfirm ? undefined : "filter",
859
1372
  });
@@ -874,6 +1387,55 @@ function buildOpenSpec(): WidgetSpec {
874
1387
  ],
875
1388
  }
876
1389
  : null;
1390
+
1391
+ // Scope chrome. The title keeps the active project visible; the
1392
+ // `Project:` control below is the clickable scope switch.
1393
+ const scope = openDialog.scope;
1394
+ const curKey = currentProjectKey();
1395
+ const curName = projectLabel(curKey);
1396
+ const scopeKey = editor.getKeybindingLabel("orchestrator_toggle_scope", OPEN_MODE);
1397
+ const titleSuffix = scope === "current" ? ` — ${curName}` : " — all projects";
1398
+ const sectionLabel = "Sessions";
1399
+ // `Project:` control — a visible, clickable scope switch with the
1400
+ // Alt+P hint baked into the button label. Shows the current
1401
+ // project's name when scoped, "All" when showing every project.
1402
+ // Inert while a confirm prompt is up so it can't steal focus.
1403
+ const scopeWord = scope === "current" ? editor.pathBasename(curKey) : "All";
1404
+ const scopeButtonLabel = scopeKey ? `${scopeWord} ▾ (${scopeKey})` : `${scopeWord} ▾`;
1405
+ const scopeButton = button(scopeButtonLabel, {
1406
+ key: openDialog.pendingConfirm !== null ? undefined : "scope-toggle",
1407
+ });
1408
+ const projectControlRow = row(
1409
+ {
1410
+ kind: "raw",
1411
+ entries: [
1412
+ styledRow([{ text: "Project: ", style: { fg: "ui.menu_disabled_fg" } }]),
1413
+ ],
1414
+ },
1415
+ scopeButton,
1416
+ flexSpacer(),
1417
+ );
1418
+ // Per-project filter checkbox, on its own row under the Project
1419
+ // control: opt-in toggle that surfaces the discovered on-disk
1420
+ // worktree rows. A `toggle` (single `[ ]`/`[v]` — no double
1421
+ // bracket) that's clickable and bound to Alt+T
1422
+ // (`orchestrator_toggle_worktrees`, rebindable). The label carries
1423
+ // the live keybinding hint, mirroring the Project control's
1424
+ // "(Alt+P)". Inert while a confirm prompt is up.
1425
+ const worktreeKey = editor.getKeybindingLabel(
1426
+ "orchestrator_toggle_worktrees",
1427
+ OPEN_MODE,
1428
+ );
1429
+ const worktreeLabel = worktreeKey
1430
+ ? `Show all worktrees (${worktreeKey})`
1431
+ : "Show all worktrees";
1432
+ const worktreeFilterRow = row(
1433
+ toggle(openDialog.showWorktrees, worktreeLabel, {
1434
+ key: openDialog.pendingConfirm !== null ? undefined : "worktree-show",
1435
+ }),
1436
+ flexSpacer(),
1437
+ );
1438
+
877
1439
  return col(
878
1440
  {
879
1441
  kind: "raw",
@@ -883,6 +1445,10 @@ function buildOpenSpec(): WidgetSpec {
883
1445
  text: "ORCHESTRATOR :: Sessions",
884
1446
  style: { fg: "ui.popup_border_fg", bold: true },
885
1447
  },
1448
+ {
1449
+ text: titleSuffix,
1450
+ style: { fg: "ui.menu_disabled_fg" },
1451
+ },
886
1452
  ]),
887
1453
  ],
888
1454
  },
@@ -896,14 +1462,18 @@ function buildOpenSpec(): WidgetSpec {
896
1462
  // the dialog.
897
1463
  row(
898
1464
  labeledSection({
899
- label: `Sessions (${filtered.length})`,
900
- widthPct: 25,
901
- // Sessions column: new-session button, separator,
902
- // filter, separator, list. The button is first so it
903
- // gets initial focus (Enter immediately opens the new
904
- // session form). Separators are long `─` strings that
905
- // the renderer truncates to the column's inner width —
906
- // no need to measure cells from the plugin side.
1465
+ label: sectionLabel,
1466
+ // 34% (was 25%): wide enough that the per-row project tag in
1467
+ // the all-projects view (`· <project>`) and longer session
1468
+ // labels render without truncating to tmp_o…`. The preview
1469
+ // pane still keeps the majority for the live window embed.
1470
+ widthPct: 34,
1471
+ // Sessions column: New button, Project (scope) control,
1472
+ // Filter, separator, column header, list. The button is
1473
+ // first so it gets initial focus (Enter immediately opens the
1474
+ // new session form). Separators are long `─` strings that the
1475
+ // renderer truncates to the column's inner width — no need to
1476
+ // measure cells from the plugin side.
907
1477
  child: col(
908
1478
  row(
909
1479
  button(newLabel, {
@@ -918,13 +1488,20 @@ function buildOpenSpec(): WidgetSpec {
918
1488
  }),
919
1489
  flexSpacer(),
920
1490
  ),
921
- sessionsSeparator(),
1491
+ projectControlRow,
1492
+ worktreeFilterRow,
922
1493
  filterInput,
923
1494
  sessionsSeparator(),
1495
+ sessionsColumnHeader(),
924
1496
  list({
925
1497
  items,
926
1498
  itemKeys,
927
1499
  selectedIndex: selIdx,
1500
+ // `listVisibleRows` is the fitted list height; the 5 rows
1501
+ // of column chrome above it (New / Project / Filter /
1502
+ // separator / header) and the matching preview embed are
1503
+ // accounted for separately so both panes stay the same
1504
+ // height and the footer hint stays on-screen.
928
1505
  visibleRows: openDialog.listVisibleRows,
929
1506
  // Excluded from the Tab cycle — Up/Down on the
930
1507
  // filter input forwards to this list via host
@@ -944,8 +1521,7 @@ function buildOpenSpec(): WidgetSpec {
944
1521
  ),
945
1522
  }),
946
1523
  // Preview pane has no explicit width — picks up the
947
- // remaining 75% by default since the sessions list took
948
- // 25%.
1524
+ // remaining width by default since the sessions list took 34%.
949
1525
  buildPreviewPane(selectedSession),
950
1526
  ),
951
1527
  row(
@@ -953,6 +1529,15 @@ function buildOpenSpec(): WidgetSpec {
953
1529
  hintBar([
954
1530
  { keys: "↑↓", label: "nav" },
955
1531
  { keys: "Enter", label: "dive" },
1532
+ {
1533
+ keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
1534
+ "Space",
1535
+ label: "select",
1536
+ },
1537
+ {
1538
+ keys: scopeKey || "⌥P",
1539
+ label: scope === "current" ? "all projects" : "current only",
1540
+ },
956
1541
  { keys: "Tab", label: "focus" },
957
1542
  { keys: "Esc", label: "close" },
958
1543
  ]),
@@ -1009,6 +1594,7 @@ function clearDialogError(): void {
1009
1594
 
1010
1595
  function refreshOpenDialog(): void {
1011
1596
  if (!openPanel || !openDialog) return;
1597
+ pruneSelection();
1012
1598
  openDialog.filteredIds = filterSessions(openDialog.filter.value);
1013
1599
  // Clamp the selection into range so a fresh filter or a
1014
1600
  // session vanishing under us doesn't leave us pointing past
@@ -1032,7 +1618,9 @@ function openControlRoom(): void {
1032
1618
  if (openPanel) return;
1033
1619
  reconcileSessions();
1034
1620
  const activeId = editor.activeWindow();
1035
- const listVisibleRows = openListVisibleRows();
1621
+ // Seed with the screen-max; buildOpenSpec refits to the session
1622
+ // count on the first render (and every render after).
1623
+ const listVisibleRows = maxListRowsForScreen();
1036
1624
  openDialog = {
1037
1625
  filter: { value: "", cursor: 0 },
1038
1626
  filteredIds: [],
@@ -1040,19 +1628,16 @@ function openControlRoom(): void {
1040
1628
  originalActiveSession: activeId,
1041
1629
  pendingConfirm: null,
1042
1630
  listVisibleRows,
1043
- // Mirror buildPreviewPane's chrome: 1 button row + 1 spacer
1044
- // + 2 info rows + 1 spacer = 4 rows reserved above the embed.
1045
- // Preview chrome above the embed: 1 button row + 1 spacer + 2
1046
- // info rows + 1 spacer = 5 rows. The labeledSection's top/bottom
1047
- // borders match the sessions list's, so subtracting just the
1048
- // chrome makes the preview pane's apparent height match the
1049
- // list pane's (`visible_rows + 2 borders`) exactly. Floored at
1050
- // 3 so a tiny terminal still leaves enough rows for the embed
1051
- // to paint something meaningful.
1052
- embedRows: Math.max(3, listVisibleRows - 5),
1631
+ embedRows: Math.max(3, listVisibleRows + 3),
1053
1632
  showDetails: false,
1054
1633
  inFlight: null,
1055
1634
  lastError: null,
1635
+ // Restore the last-used scope (defaults to "all"); the Project
1636
+ // control / Alt+P updates it for next time.
1637
+ scope: lastOpenScope,
1638
+ selectedIds: new Set<number>(),
1639
+ showWorktrees: lastShowWorktrees,
1640
+ bulkInFlight: null,
1056
1641
  };
1057
1642
  openDialog.filteredIds = filterSessions("");
1058
1643
  const activeIdx = openDialog.filteredIds.indexOf(activeId);
@@ -1074,6 +1659,12 @@ function openControlRoom(): void {
1074
1659
  // safe — there's nothing to act on then anyway.
1075
1660
  openPanel.setFocusKey("visit");
1076
1661
  editor.setEditorMode(OPEN_MODE);
1662
+
1663
+ // Discover worktrees that exist on disk but aren't open yet and
1664
+ // fold them into the list. Async (it shells out to git per
1665
+ // project); the dialog renders immediately with live sessions and
1666
+ // gains the discovered rows when the scan lands.
1667
+ void refreshDiscoveredWorktrees();
1077
1668
  }
1078
1669
 
1079
1670
  function closeOpenDialog(): void {
@@ -1085,33 +1676,27 @@ function closeOpenDialog(): void {
1085
1676
  editor.setEditorMode(null);
1086
1677
  }
1087
1678
 
1088
- // Stop every process the highlighted session owns. Sends
1089
- // SIGTERM first via the host's `signalWindow` (which fans
1090
- // out through the window's process-group tracker), then
1091
- // follows up with SIGKILL after a short grace period so
1092
- // ill-behaved agents that ignore SIGTERM still get reaped.
1093
- // The session record stays put Stop only kills processes,
1094
- // it doesn't touch the worktree or the editor session.
1095
- function stopSelectedSession(): void {
1096
- if (!openDialog) return;
1097
- const id = openDialog.filteredIds[openDialog.selectedIndex];
1098
- if (typeof id !== "number" || id <= 0) return;
1099
- if (id === 1) {
1100
- setDialogError("cannot stop the base session");
1101
- refreshOpenDialog();
1102
- return;
1103
- }
1679
+ // Stop every process one session owns. Sends SIGTERM first via the
1680
+ // host's `signalWindow` (which fans out through the window's
1681
+ // process-group tracker), then follows up with SIGKILL after a short
1682
+ // grace period so ill-behaved agents that ignore SIGTERM still get
1683
+ // reaped. The session record stays put Stop only kills processes,
1684
+ // it doesn't touch the worktree or the editor session. Returns false
1685
+ // for ids it can't stop (base session, discovered worktrees with no
1686
+ // live window).
1687
+ function stopOne(id: number): boolean {
1688
+ const s = orchestratorSessions.get(id);
1689
+ if (!s || id <= 0 || id === 1 || s.discovered) return false;
1104
1690
  editor.signalWindow(id, "SIGTERM");
1105
- // SIGKILL fallback for agents that ignore SIGTERM. The
1106
- // host's signalWindow is idempotent on already-exited
1107
- // process groups, so the second call is safe whether or
1108
- // not the first one took. QuickJS has no `setTimeout`;
1109
- // the host exposes `editor.delay(ms)` as the asynchronous
1691
+ // SIGKILL fallback for agents that ignore SIGTERM. The host's
1692
+ // signalWindow is idempotent on already-exited process groups, so
1693
+ // the second call is safe whether or not the first one took.
1694
+ // QuickJS has no `setTimeout`; `editor.delay(ms)` is the async
1110
1695
  // sleep primitive, which we kick off but don't await.
1111
1696
  void editor.delay(2000).then(() => {
1112
1697
  editor.signalWindow(id, "SIGKILL");
1113
1698
  });
1114
- editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
1699
+ return true;
1115
1700
  }
1116
1701
 
1117
1702
  // ---------------------------------------------------------------------
@@ -1194,136 +1779,97 @@ function pickNextActiveSession(excludeId: number): number {
1194
1779
  return 1;
1195
1780
  }
1196
1781
 
1197
- // Archive flow: stop all processes (SIGKILL archive is a
1198
- // "I'm done with this for now" action, no graceful teardown
1199
- // needed since the worktree stays on disk), close the editor
1200
- // session, move the worktree to the `.archived/` graveyard,
1201
- // and append a manifest entry so a future Unarchive flow can
1202
- // reverse it.
1203
- async function archiveSelectedSession(explicitId?: number): Promise<void> {
1204
- if (!openDialog) return;
1205
- // Prefer the explicit id from the confirm path. Otherwise read
1206
- // the currently selected row — used by the legacy direct-call
1207
- // entry points. Once the row is hidden synchronously after
1208
- // confirm, `filteredIds[selectedIndex]` no longer points at the
1209
- // session being archived (it shifts to whatever is now under
1210
- // the cursor).
1211
- const id = typeof explicitId === "number"
1212
- ? explicitId
1213
- : openDialog.filteredIds[openDialog.selectedIndex];
1214
- // Clear the in-flight marker so the preview pane stops showing
1215
- // "Archiving…" if the operation refuses or fails. After
1216
- // `closeWindow` succeeds the row is gone from `listWindows()`
1217
- // anyway, so clearing then is harmless.
1218
- const clearInFlight = () => {
1219
- if (
1220
- openDialog?.inFlight && typeof id === "number" &&
1221
- openDialog.inFlight.sessionId === id
1222
- ) {
1223
- openDialog.inFlight = null;
1224
- refreshOpenDialog();
1225
- }
1226
- };
1227
- if (typeof id !== "number" || id <= 0) return;
1228
- if (id === 1) {
1229
- setDialogError("cannot archive the base session");
1230
- clearInFlight();
1231
- return;
1232
- }
1233
- // close_window refuses to close the active window; swap to a
1234
- // different session first. The pick prefers something already
1235
- // in the dialog's current filter, falls back to the base
1236
- // session — both always exist (base is undeletable, and we'd
1237
- // have nothing to archive without at least one session).
1238
- if (id === editor.activeWindow()) {
1239
- editor.setActiveWindow(pickNextActiveSession(id));
1240
- }
1241
- const session = orchestratorSessions.get(id);
1242
- if (!session) {
1243
- clearInFlight();
1244
- return;
1245
- }
1246
-
1247
- // Resolve the repo root from cwd (the user is in the
1248
- // umbrella session's tree).
1249
- const cwd = editor.getCwd();
1250
- const top = await spawnCollect(
1251
- "git",
1252
- ["rev-parse", "--show-toplevel"],
1253
- cwd,
1254
- );
1255
- if (top.exit_code !== 0) {
1256
- editor.setStatus("Orchestrator: archive failed — not a git repository");
1257
- clearInFlight();
1258
- return;
1782
+ // Resolve the *main* repo root a session's worktree belongs to, so
1783
+ // `git worktree move/remove` runs from a stable directory (never from
1784
+ // inside the tree being moved/removed). Prefers the canonical
1785
+ // `projectPath` recorded at create/discovery time, falling back to
1786
+ // resolving from the worktree itself.
1787
+ async function worktreeRepoRoot(s: AgentSession): Promise<string | null> {
1788
+ if (s.projectPath) {
1789
+ const r = await resolveCanonicalRepoRoot(s.projectPath);
1790
+ if (r) return r;
1259
1791
  }
1260
- const repoRoot = (top.stdout || "").trim();
1792
+ return await resolveCanonicalRepoRoot(s.root);
1793
+ }
1261
1794
 
1262
- // SIGKILL the session's process group so the pty children
1263
- // release any locks on the worktree, then close the editor
1264
- // session. closeWindow already kills the pty via the child
1265
- // killer; signaling first via the window-level pg tracker
1266
- // catches stray subprocesses outside the pty.
1267
- editor.signalWindow(id, "SIGKILL");
1268
- editor.closeWindow(id);
1795
+ interface LifecycleResult {
1796
+ ok: boolean;
1797
+ err?: string;
1798
+ repoRoot?: string;
1799
+ }
1269
1800
 
1270
- // Brief settle so the filesystem reflects the pty's exit
1271
- // before we move the worktree out from under it.
1272
- await editor.delay(250);
1801
+ // Archive a single session: SIGKILL its processes (archive is a
1802
+ // "done with this for now" action no graceful teardown needed since
1803
+ // the worktree stays on disk), close the editor session, move the
1804
+ // worktree to the `.archived/` graveyard, and append a manifest
1805
+ // entry so Unarchive can reverse it. Handles both live sessions and
1806
+ // discovered on-disk worktrees (the latter have no window to close).
1807
+ // Does NOT trigger sync — the caller batches one sync per repo after
1808
+ // the whole run.
1809
+ async function archiveOne(id: number): Promise<LifecycleResult> {
1810
+ const s = orchestratorSessions.get(id);
1811
+ if (!s) return { ok: false, err: "session gone" };
1812
+ if (id === 1) return { ok: false, err: "cannot archive the base session" };
1813
+ const repoRoot = await worktreeRepoRoot(s);
1814
+ if (!repoRoot) return { ok: false, err: "not a git repository" };
1815
+
1816
+ // Live session: close_window refuses to close the active window, so
1817
+ // switch away first, then SIGKILL the process group (so pty
1818
+ // children release worktree locks) and close the editor session.
1819
+ if (!s.discovered && id > 0) {
1820
+ if (id === editor.activeWindow()) {
1821
+ editor.setActiveWindow(pickNextActiveSession(id));
1822
+ }
1823
+ editor.signalWindow(id, "SIGKILL");
1824
+ editor.closeWindow(id);
1825
+ // Brief settle so the filesystem reflects the pty's exit before
1826
+ // we move the worktree out from under it.
1827
+ await editor.delay(250);
1828
+ }
1273
1829
 
1274
- // git worktree move keeps git's internal bookkeeping
1275
- // consistent (the new path stays registered as a worktree).
1276
1830
  const archivedRoot = editor.pathJoin(
1277
1831
  editor.getDataDir(),
1278
1832
  "orchestrator",
1279
1833
  slugify(repoRoot),
1280
1834
  ".archived",
1281
- session.label,
1835
+ s.label,
1282
1836
  );
1283
1837
  const parent = editor.pathDirname(archivedRoot);
1284
1838
  if (!editor.createDir(parent)) {
1285
- editor.setStatus(
1286
- `Orchestrator: archive failed — could not create ${parent}`,
1287
- );
1288
- clearInFlight();
1289
- return;
1839
+ return { ok: false, err: `could not create ${parent}`, repoRoot };
1290
1840
  }
1841
+ // git worktree move keeps git's internal bookkeeping consistent
1842
+ // (the new path stays registered as a worktree).
1291
1843
  const moveRes = await spawnCollect(
1292
1844
  "git",
1293
- ["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
1845
+ ["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
1294
1846
  repoRoot,
1295
1847
  );
1296
1848
  if (moveRes.exit_code !== 0) {
1297
- editor.setStatus(
1298
- `Orchestrator: worktree move failed: ${
1299
- lastNonEmptyLine(moveRes.stderr) || "unknown error"
1300
- }`,
1301
- );
1302
- clearInFlight();
1303
- return;
1849
+ return {
1850
+ ok: false,
1851
+ err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
1852
+ repoRoot,
1853
+ };
1304
1854
  }
1305
1855
 
1306
- // Append manifest entry. The branch info is best-effort:
1307
- // we assume Orchestrator's convention of branch==label (set in
1308
- // the new-session form) until a session knows its branch
1309
- // separately.
1310
1856
  const manifest = loadArchiveManifest(repoRoot);
1311
1857
  manifest.sessions.push({
1312
- label: session.label,
1858
+ label: s.label,
1313
1859
  root: archivedRoot,
1314
- original_root: session.root,
1315
- branch: session.label,
1860
+ original_root: s.root,
1861
+ branch: s.branch || s.label,
1316
1862
  archived_at: new Date().toISOString(),
1317
1863
  });
1318
- if (!saveArchiveManifest(repoRoot, manifest)) {
1319
- editor.setStatus(
1320
- "Orchestrator: archived, but failed to write archived.json",
1321
- );
1322
- } else {
1323
- editor.setStatus(`Orchestrator: archived [${id}] ${session.label}`);
1864
+ saveArchiveManifest(repoRoot, manifest);
1865
+
1866
+ // A discovered row has no window_closed hook to drop it — remove it
1867
+ // from the model directly.
1868
+ if (s.discovered) {
1869
+ orchestratorSessions.delete(id);
1870
+ discoveredIdByPath.delete(s.root);
1324
1871
  }
1325
- clearInFlight();
1326
- triggerSyncAsync(repoRoot);
1872
+ return { ok: true, repoRoot };
1327
1873
  }
1328
1874
 
1329
1875
  // ---------------------------------------------------------------------
@@ -1526,86 +2072,135 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
1526
2072
  };
1527
2073
  }
1528
2074
 
1529
- // Delete flow: stop processes (SIGKILL), close the editor
1530
- // session, then `git worktree remove --force` to drop the
1531
- // worktree from disk. If the session was archived (manifest
1532
- // entry exists), the manifest entry is dropped too. No
1533
- // recovery after this point.
1534
- async function deleteConfirmedSession(): Promise<void> {
1535
- if (!openDialog || !openDialog.pendingConfirm) return;
1536
- const { sessionId: id } = openDialog.pendingConfirm;
1537
- openDialog.pendingConfirm = null;
1538
- // Clear the in-flight marker on early failure. Mirrors the
1539
- // pattern in `archiveSelectedSession` — the confirm-delete
1540
- // handler set `inFlight` before kicking off this async work,
1541
- // and any path that aborts before `closeWindow` needs to undo
1542
- // it so the "Deleting…" overlay disappears.
1543
- const clearInFlight = () => {
1544
- if (openDialog?.inFlight && openDialog.inFlight.sessionId === id) {
1545
- openDialog.inFlight = null;
1546
- refreshOpenDialog();
2075
+ // Delete a single session: stop processes (SIGKILL), close the
2076
+ // editor session, then `git worktree remove --force` to drop the
2077
+ // worktree from disk. If the session was archived (manifest entry
2078
+ // exists), the manifest entry is dropped too. Handles discovered
2079
+ // on-disk worktrees (no window to close). No recovery after this
2080
+ // point. Does NOT trigger sync — the caller batches it.
2081
+ async function deleteOne(id: number): Promise<LifecycleResult> {
2082
+ const s = orchestratorSessions.get(id);
2083
+ if (!s) return { ok: false, err: "session gone" };
2084
+ if (id === 1) return { ok: false, err: "cannot delete the base session" };
2085
+ const repoRoot = await worktreeRepoRoot(s);
2086
+ if (!repoRoot) return { ok: false, err: "not a git repository" };
2087
+
2088
+ if (!s.discovered && id > 0) {
2089
+ // close_window refuses to close the active window, so swap away.
2090
+ if (id === editor.activeWindow()) {
2091
+ editor.setActiveWindow(pickNextActiveSession(id));
1547
2092
  }
1548
- };
1549
- const session = orchestratorSessions.get(id);
1550
- if (!session) {
1551
- clearInFlight();
1552
- return;
1553
- }
1554
- // Same auto-switch as archive — close_window refuses to close
1555
- // the active window, so swap to a different session first.
1556
- if (id === editor.activeWindow()) {
1557
- editor.setActiveWindow(pickNextActiveSession(id));
1558
- }
1559
-
1560
- const cwd = editor.getCwd();
1561
- const top = await spawnCollect(
1562
- "git",
1563
- ["rev-parse", "--show-toplevel"],
1564
- cwd,
1565
- );
1566
- if (top.exit_code !== 0) {
1567
- editor.setStatus("Orchestrator: delete failed — not a git repository");
1568
- clearInFlight();
1569
- return;
2093
+ editor.signalWindow(id, "SIGKILL");
2094
+ editor.closeWindow(id);
2095
+ await editor.delay(250);
1570
2096
  }
1571
- const repoRoot = (top.stdout || "").trim();
1572
-
1573
- editor.signalWindow(id, "SIGKILL");
1574
- editor.closeWindow(id);
1575
- await editor.delay(250);
1576
2097
 
1577
- // `--force` because the worktree may have unstaged changes
1578
- // the user explicitly chose to discard via the confirm step.
2098
+ // `--force` because the worktree may have unstaged changes the user
2099
+ // explicitly chose to discard via the confirm step.
1579
2100
  const removeRes = await spawnCollect(
1580
2101
  "git",
1581
- ["-C", repoRoot, "worktree", "remove", "--force", session.root],
2102
+ ["-C", repoRoot, "worktree", "remove", "--force", s.root],
1582
2103
  repoRoot,
1583
2104
  );
1584
2105
  if (removeRes.exit_code !== 0) {
1585
- editor.setStatus(
1586
- `Orchestrator: worktree remove failed: ${
1587
- lastNonEmptyLine(removeRes.stderr) || "unknown error"
1588
- }`,
1589
- );
1590
- clearInFlight();
1591
- return;
2106
+ return {
2107
+ ok: false,
2108
+ err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
2109
+ repoRoot,
2110
+ };
1592
2111
  }
1593
2112
 
1594
- // Drop the matching manifest entry too, in case the session
1595
- // was already archived (delete-from-archived is the natural
1596
- // way to drop dormant sessions).
2113
+ // Drop the matching manifest entry too, in case the session was
2114
+ // already archived (delete-from-archived is the natural way to drop
2115
+ // dormant sessions).
1597
2116
  const manifest = loadArchiveManifest(repoRoot);
1598
2117
  const before = manifest.sessions.length;
1599
- manifest.sessions = manifest.sessions.filter(
1600
- (e) => e.label !== session.label,
1601
- );
2118
+ manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
1602
2119
  if (manifest.sessions.length !== before) {
1603
2120
  saveArchiveManifest(repoRoot, manifest);
1604
2121
  }
1605
2122
 
1606
- editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
1607
- clearInFlight();
1608
- triggerSyncAsync(repoRoot);
2123
+ if (s.discovered) {
2124
+ orchestratorSessions.delete(id);
2125
+ discoveredIdByPath.delete(s.root);
2126
+ }
2127
+ return { ok: true, repoRoot };
2128
+ }
2129
+
2130
+ // Unified runner for a confirmed Stop / Archive / Delete over one or
2131
+ // many ids. Re-filters to eligible targets at execution time (the
2132
+ // selection or single row may have gone stale between confirm and
2133
+ // run), drives the in-flight progress markers, runs the per-id cores
2134
+ // sequentially, prunes acted-on ids from the selection, and triggers
2135
+ // one sync per touched repo at the end.
2136
+ async function runConfirmedAction(
2137
+ action: BulkAction,
2138
+ ids: number[],
2139
+ ): Promise<void> {
2140
+ if (!openDialog) return;
2141
+ const targets = ids.filter((id) => bulkEligible(action, id));
2142
+ if (targets.length === 0) {
2143
+ setDialogError(`nothing eligible to ${action} in the selection`);
2144
+ refreshOpenDialog();
2145
+ return;
2146
+ }
2147
+
2148
+ if (action === "stop") {
2149
+ let n = 0;
2150
+ for (const id of targets) if (stopOne(id)) n += 1;
2151
+ editor.setStatus(`Orchestrator: stop signal sent to ${n} session(s)`);
2152
+ // Stop leaves sessions in place; drop them from the selection so
2153
+ // the bulk bar reflects that the action ran.
2154
+ for (const id of targets) openDialog.selectedIds.delete(id);
2155
+ refreshOpenDialog();
2156
+ return;
2157
+ }
2158
+
2159
+ const single = targets.length === 1;
2160
+ if (single) {
2161
+ openDialog.inFlight = { action, sessionId: targets[0] };
2162
+ } else {
2163
+ openDialog.bulkInFlight = { action, total: targets.length, done: 0 };
2164
+ }
2165
+ refreshOpenDialog();
2166
+
2167
+ const touchedRepos = new Set<string>();
2168
+ let okCount = 0;
2169
+ let lastErr = "";
2170
+ for (let i = 0; i < targets.length; i++) {
2171
+ const id = targets[i];
2172
+ const res = action === "archive" ? await archiveOne(id) : await deleteOne(id);
2173
+ if (res.ok) {
2174
+ okCount += 1;
2175
+ if (res.repoRoot) touchedRepos.add(res.repoRoot);
2176
+ } else {
2177
+ lastErr = res.err ?? "failed";
2178
+ }
2179
+ openDialog?.selectedIds.delete(id);
2180
+ if (openDialog?.bulkInFlight) openDialog.bulkInFlight.done = i + 1;
2181
+ refreshOpenDialog();
2182
+ }
2183
+ if (openDialog) {
2184
+ openDialog.inFlight = null;
2185
+ openDialog.bulkInFlight = null;
2186
+ }
2187
+
2188
+ const verb = action === "archive" ? "archived" : "deleted";
2189
+ if (okCount === 0) {
2190
+ setDialogError(`${action} failed: ${lastErr || "unknown error"}`);
2191
+ } else if (lastErr) {
2192
+ setDialogError(`${verb} ${okCount}/${targets.length}; last error: ${lastErr}`);
2193
+ } else {
2194
+ editor.setStatus(`Orchestrator: ${verb} ${okCount} session(s)`);
2195
+ }
2196
+ for (const repo of touchedRepos) triggerSyncAsync(repo);
2197
+ refreshOpenDialog();
2198
+ // The batch emptied the selection, so the pane is back in
2199
+ // single-preview mode — restore focus to Visit (the bulk buttons
2200
+ // it may have been on are gone).
2201
+ if (openPanel && selectedSessions().length < 2 && !openDialog.pendingConfirm) {
2202
+ openPanel.setFocusKey("visit");
2203
+ }
1609
2204
  }
1610
2205
 
1611
2206
  // `Alt+N` from inside the picker opens the new-session form — saves
@@ -1616,7 +2211,33 @@ async function deleteConfirmedSession(): Promise<void> {
1616
2211
  // since OPEN_MODE doesn't claim them here.
1617
2212
  editor.defineMode(
1618
2213
  OPEN_MODE,
1619
- [["M-n", "orchestrator_open_new_from_picker"]],
2214
+ [
2215
+ ["M-n", "orchestrator_open_new_from_picker"],
2216
+ // Scope toggle: flip the list between "current project only"
2217
+ // and "all projects". Registered as a mode chord so it's
2218
+ // user-rebindable and renders cross-platform (⌥P / Alt+P).
2219
+ ["M-p", "orchestrator_toggle_scope"],
2220
+ // `/` jumps focus to the filter input — the familiar
2221
+ // search-focus shortcut. (As a mode chord it's intercepted even
2222
+ // while the filter has focus, so `/` can't be typed as filter
2223
+ // text; session names don't contain `/`, so that's an
2224
+ // acceptable trade for the quick-focus.)
2225
+ ["/", "orchestrator_focus_filter"],
2226
+ // Space toggles the highlighted row's membership in the bulk
2227
+ // selection. Bound as a mode chord (not a widget smart-key) so
2228
+ // it's user-rebindable in the keybinding editor and fires
2229
+ // regardless of which control holds focus — the host's
2230
+ // `dispatch_floating_widget_key` defers any explicitly-bound
2231
+ // mode key, including bare chars, before the text-input path.
2232
+ // The trade (same as `/`) is that Space can't be typed into the
2233
+ // filter while the picker is open; session names don't contain
2234
+ // spaces, so that's acceptable.
2235
+ ["Space", "orchestrator_toggle_select"],
2236
+ // Alt+T toggles "Show all worktrees" — the opt-in filter that
2237
+ // surfaces discovered on-disk worktree rows. Rebindable, same as
2238
+ // the scope toggle.
2239
+ ["M-t", "orchestrator_toggle_worktrees"],
2240
+ ],
1620
2241
  true,
1621
2242
  true,
1622
2243
  );
@@ -1627,6 +2248,86 @@ registerHandler("orchestrator_open_new_from_picker", () => {
1627
2248
  openForm({ fromPicker: true });
1628
2249
  });
1629
2250
 
2251
+ registerHandler("orchestrator_focus_filter", () => {
2252
+ if (!openDialog || !openPanel) return;
2253
+ openPanel.setFocusKey("filter");
2254
+ });
2255
+
2256
+ // Space (rebindable): toggle the highlighted row in/out of the bulk
2257
+ // selection. Manages focus across the single↔bulk transition: when
2258
+ // the second row is checked the preview pane swaps to the bulk bar
2259
+ // (so the now-absent "visit" focus would otherwise be clamped to a
2260
+ // random tabbable), and when the selection drops back below two the
2261
+ // per-session preview — with its "visit" button — returns.
2262
+ registerHandler("orchestrator_toggle_select", () => {
2263
+ if (!openDialog || !openPanel) return;
2264
+ // Inert while a confirm prompt is up — the selection is frozen
2265
+ // behind the confirmation panel.
2266
+ if (openDialog.pendingConfirm) return;
2267
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
2268
+ if (typeof id !== "number") return;
2269
+ const wasBulk = selectedSessions().length >= 2;
2270
+ if (openDialog.selectedIds.has(id)) {
2271
+ openDialog.selectedIds.delete(id);
2272
+ } else {
2273
+ openDialog.selectedIds.add(id);
2274
+ }
2275
+ clearDialogError();
2276
+ refreshOpenDialog();
2277
+ const isBulk = selectedSessions().length >= 2;
2278
+ if (!wasBulk && isBulk) {
2279
+ // Entering bulk mode — land focus on a bulk button (Up/Down from
2280
+ // a button still drives the list, so navigation keeps working).
2281
+ openPanel.setFocusKey("bulk-archive");
2282
+ } else if (wasBulk && !isBulk) {
2283
+ // Back to single preview — restore focus to Visit.
2284
+ openPanel.setFocusKey("visit");
2285
+ }
2286
+ });
2287
+
2288
+ function toggleScope(): void {
2289
+ if (!openDialog) return;
2290
+ openDialog.scope = openDialog.scope === "current" ? "all" : "current";
2291
+ // Remember the choice for the next time the picker opens.
2292
+ lastOpenScope = openDialog.scope;
2293
+ // Keep the highlighted session selected across the scope flip
2294
+ // when it survives into the new list; otherwise fall back to the
2295
+ // top. The filter value is untouched — toggling scope with an
2296
+ // active filter just widens/narrows the global-search base.
2297
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2298
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2299
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2300
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2301
+ refreshOpenDialog();
2302
+ }
2303
+
2304
+ registerHandler("orchestrator_toggle_scope", toggleScope);
2305
+
2306
+ // Flip "Show all worktrees" — reveal/hide the discovered on-disk
2307
+ // worktree rows. Preserves the highlighted row across the re-filter
2308
+ // where possible; drops now-hidden discovered rows from the bulk
2309
+ // selection. Shared by the Alt+T chord and the checkbox click.
2310
+ function toggleShowWorktrees(): void {
2311
+ if (!openDialog) return;
2312
+ openDialog.showWorktrees = !openDialog.showWorktrees;
2313
+ lastShowWorktrees = openDialog.showWorktrees;
2314
+ // Hiding worktrees shouldn't leave them lingering in the selection.
2315
+ if (!openDialog.showWorktrees) {
2316
+ for (const id of [...openDialog.selectedIds]) {
2317
+ if (orchestratorSessions.get(id)?.discovered) {
2318
+ openDialog.selectedIds.delete(id);
2319
+ }
2320
+ }
2321
+ }
2322
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2323
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2324
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2325
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2326
+ refreshOpenDialog();
2327
+ }
2328
+
2329
+ registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
2330
+
1630
2331
  // =============================================================================
1631
2332
  // New-session floating form
1632
2333
  // =============================================================================
@@ -1929,6 +2630,130 @@ async function pathIsInsideGitWorkTree(
1929
2630
  return (res.stdout || "").trim() === "true";
1930
2631
  }
1931
2632
 
2633
+ // =============================================================================
2634
+ // Worktree classification & discovery
2635
+ //
2636
+ // Two distinct git facts drive the "attach to an existing worktree"
2637
+ // flows:
2638
+ //
2639
+ // * `classifyWorktree(path)` answers "is this path a *linked*
2640
+ // worktree, and if so what repo does it belong to?" — used by
2641
+ // the new-session form to attach (rather than fork) when the
2642
+ // user points Project Path at an existing worktree.
2643
+ // * `listLinkedWorktrees(repoRoot)` enumerates every linked
2644
+ // worktree of a repo (via `git worktree list --porcelain`) —
2645
+ // used to surface on-disk worktrees in the Open dialog without
2646
+ // the user adding them by hand.
2647
+ // =============================================================================
2648
+
2649
+ interface WorktreeInfo {
2650
+ // `git rev-parse --show-toplevel` for the path.
2651
+ toplevel: string;
2652
+ // Canonical main-worktree root (dirname of `--git-common-dir`).
2653
+ // This is the repo the worktree belongs to, used as the
2654
+ // session's `projectPath` so attached worktrees group under
2655
+ // their repo in the picker.
2656
+ mainRoot: string;
2657
+ // `true` when the path is a *linked* worktree (its per-worktree
2658
+ // git dir differs from the shared common dir), i.e. a tree
2659
+ // created by `git worktree add` rather than the main checkout.
2660
+ isLinked: boolean;
2661
+ // Branch checked out there (`refs/heads/<name>` short form), or
2662
+ // empty when detached.
2663
+ branch: string;
2664
+ }
2665
+
2666
+ /// Classify `path` as a git worktree. Returns `null` when `path`
2667
+ /// is not inside any git work tree (the caller then treats it as a
2668
+ /// plain directory / shared root).
2669
+ async function classifyWorktree(path: string): Promise<WorktreeInfo | null> {
2670
+ if (!path) return null;
2671
+ const top = await spawnCollect("git", ["-C", path, "rev-parse", "--show-toplevel"], path);
2672
+ if (top.exit_code !== 0) return null;
2673
+ const toplevel = (top.stdout || "").trim();
2674
+ if (!toplevel) return null;
2675
+
2676
+ // The per-worktree git dir vs. the shared common dir: they are
2677
+ // equal for the main worktree and differ for every linked
2678
+ // worktree (`<common>/worktrees/<id>`). That difference is the
2679
+ // canonical "is this a linked worktree?" test.
2680
+ const [gitDir, commonDir] = await Promise.all([
2681
+ spawnCollect("git", ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-dir"], toplevel),
2682
+ spawnCollect(
2683
+ "git",
2684
+ ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-common-dir"],
2685
+ toplevel,
2686
+ ),
2687
+ ]);
2688
+ const gd = gitDir.exit_code === 0 ? (gitDir.stdout || "").trim() : "";
2689
+ const cd = commonDir.exit_code === 0 ? (commonDir.stdout || "").trim() : "";
2690
+ const isLinked = gd !== "" && cd !== "" && gd !== cd;
2691
+ const mainRoot = cd ? editor.pathDirname(cd) : toplevel;
2692
+
2693
+ const head = await spawnCollect(
2694
+ "git",
2695
+ ["-C", toplevel, "rev-parse", "--abbrev-ref", "HEAD"],
2696
+ toplevel,
2697
+ );
2698
+ let branch = head.exit_code === 0 ? (head.stdout || "").trim() : "";
2699
+ if (branch === "HEAD") branch = ""; // detached
2700
+
2701
+ return { toplevel, mainRoot, isLinked, branch };
2702
+ }
2703
+
2704
+ interface ParsedWorktree {
2705
+ path: string;
2706
+ branch: string;
2707
+ detached: boolean;
2708
+ }
2709
+
2710
+ /// Parse `git worktree list --porcelain` output. Blocks are
2711
+ /// separated by blank lines; the first block is the main worktree,
2712
+ /// the rest are linked. Each block has a `worktree <path>` line
2713
+ /// plus `branch refs/heads/<name>` or `detached`.
2714
+ function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
2715
+ const out: ParsedWorktree[] = [];
2716
+ let cur: ParsedWorktree | null = null;
2717
+ for (const raw of (stdout || "").split(/\r?\n/)) {
2718
+ const line = raw.trimEnd();
2719
+ if (line.startsWith("worktree ")) {
2720
+ if (cur) out.push(cur);
2721
+ cur = { path: line.slice("worktree ".length), branch: "", detached: false };
2722
+ } else if (cur && line.startsWith("branch ")) {
2723
+ const ref = line.slice("branch ".length);
2724
+ cur.branch = ref.replace(/^refs\/heads\//, "");
2725
+ } else if (cur && line === "detached") {
2726
+ cur.detached = true;
2727
+ } else if (line === "" && cur) {
2728
+ out.push(cur);
2729
+ cur = null;
2730
+ }
2731
+ }
2732
+ if (cur) out.push(cur);
2733
+ return out;
2734
+ }
2735
+
2736
+ /// Enumerate the *linked* worktrees of `repoRoot` (excludes the
2737
+ /// main worktree, which is the repo's own checkout). Returns the
2738
+ /// parsed entries with the main-repo root resolved so callers can
2739
+ /// tag discovered sessions with the right `projectPath`.
2740
+ async function listLinkedWorktrees(
2741
+ repoRoot: string,
2742
+ ): Promise<{ mainRoot: string; worktrees: ParsedWorktree[] } | null> {
2743
+ const res = await spawnCollect(
2744
+ "git",
2745
+ ["-C", repoRoot, "worktree", "list", "--porcelain"],
2746
+ repoRoot,
2747
+ );
2748
+ if (res.exit_code !== 0) return null;
2749
+ const all = parseWorktreePorcelain(res.stdout || "");
2750
+ if (all.length === 0) return null;
2751
+ // The first entry is always the main worktree.
2752
+ const mainRoot = all[0].path;
2753
+ const worktrees = all.slice(1);
2754
+ return { mainRoot, worktrees };
2755
+ }
2756
+
1932
2757
  async function nextAutoSessionName(
1933
2758
  repoRoot: string,
1934
2759
  options?: { persist?: boolean },
@@ -2015,9 +2840,11 @@ function buildFormSpec(): WidgetSpec {
2015
2840
  // inert when worktree creation is off.
2016
2841
  let branchPlaceholder: string;
2017
2842
  if (branchInert) {
2018
- branchPlaceholder = worktreeEnabled
2019
- ? "shared worktree — N/A"
2020
- : "no git — N/A";
2843
+ branchPlaceholder = !worktreeEnabled
2844
+ ? "no git — N/A"
2845
+ : form.projectPathIsLinkedWorktree === true
2846
+ ? "existing worktree — N/A"
2847
+ : "shared worktree — N/A";
2021
2848
  } else if (!form.defaultBranch) {
2022
2849
  branchPlaceholder = "detecting default branch…";
2023
2850
  } else if (form.defaultBranchIsHeadFallback) {
@@ -2092,6 +2919,24 @@ function buildFormSpec(): WidgetSpec {
2092
2919
  ]),
2093
2920
  ],
2094
2921
  },
2922
+ // Existing-worktree hint: when Project Path points at a linked
2923
+ // worktree, explain what the (un)checked box now means so the
2924
+ // attach behaviour isn't a silent surprise.
2925
+ ...(form.projectPathIsLinkedWorktree === true
2926
+ ? [{
2927
+ kind: "raw" as const,
2928
+ entries: [
2929
+ styledRow([
2930
+ {
2931
+ text: form.createWorktree
2932
+ ? " ↳ existing worktree here — uncheck to attach instead of forking a new one"
2933
+ : " ↳ existing worktree — this session will attach to it",
2934
+ style: { fg: "ui.help_key_fg", italic: true },
2935
+ },
2936
+ ]),
2937
+ ],
2938
+ }]
2939
+ : []),
2095
2940
  // === Form body: labeled, full-width inputs. ==================
2096
2941
  // Labels are plain — the `▸` glyph used to be baked into all
2097
2942
  // three strings and stayed put regardless of focus, which was
@@ -2210,7 +3055,6 @@ function renderForm(): void {
2210
3055
  }
2211
3056
 
2212
3057
  function openForm(options?: { fromPicker?: boolean }): void {
2213
- pendingNewSession = null;
2214
3058
  const lastCmd =
2215
3059
  (editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
2216
3060
  form = {
@@ -2232,6 +3076,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
2232
3076
  lastError: null,
2233
3077
  defaultProjectPath: "",
2234
3078
  projectPathIsGit: null,
3079
+ projectPathIsLinkedWorktree: null,
2235
3080
  defaultSessionName: "",
2236
3081
  defaultBranch: "",
2237
3082
  defaultBranchIsHeadFallback: false,
@@ -2294,6 +3139,24 @@ async function probeProjectPathDefaults(): Promise<void> {
2294
3139
  if (!form || form.probeToken !== token) return;
2295
3140
  form.projectPathIsGit = isGit;
2296
3141
 
3142
+ // (2b) Existing-linked-worktree detection. When the path is a
3143
+ // worktree created by `git worktree add` (not the repo's main
3144
+ // checkout), default the checkbox to *unchecked* so the
3145
+ // natural action is to attach to it. Only flip on the
3146
+ // detection transition so we don't fight a user who
3147
+ // deliberately re-checks "create a new worktree".
3148
+ const wasLinked = form.projectPathIsLinkedWorktree;
3149
+ if (isGit) {
3150
+ const info = await classifyWorktree(effectivePath);
3151
+ if (!form || form.probeToken !== token) return;
3152
+ form.projectPathIsLinkedWorktree = info?.isLinked === true;
3153
+ } else {
3154
+ form.projectPathIsLinkedWorktree = false;
3155
+ }
3156
+ if (form.projectPathIsLinkedWorktree && wasLinked !== true) {
3157
+ form.createWorktree = false;
3158
+ }
3159
+
2297
3160
  // (3) Default branch + session name probes only make sense on
2298
3161
  // a git path. On non-git, leave both empty (the renderer
2299
3162
  // surfaces a "no git — N/A" branch placeholder, and the
@@ -2713,14 +3576,28 @@ async function submitForm(): Promise<void> {
2713
3576
  editor.setGlobalState("orchestrator.last_cmd", cmd);
2714
3577
  }
2715
3578
 
2716
- // Branch / cmd values used for `pendingNewSession` — `branchName`
2717
- // only exists in the worktree-create flow above; for the
2718
- // shared-worktree / non-git case we report whatever's currently
2719
- // checked out (best-effort) so the new session record matches
2720
- // the situation on disk.
3579
+ // Attach-to-existing-worktree: when the user opted out of
3580
+ // creating a worktree but pointed Project Path at an *existing
3581
+ // linked worktree* (one created by `git worktree add`, possibly
3582
+ // for a repo Fresh has never opened before), treat it as the
3583
+ // dedicated worktree it is rather than a shared root. That means
3584
+ // `shared_worktree = false` (so Archive / Delete can
3585
+ // `git worktree move` / `remove` it) and a `project_path` of the
3586
+ // owning repo so the session groups with its siblings. A path
3587
+ // that's the repo's *main* worktree, or a non-git directory, stays
3588
+ // shared — you can't `git worktree remove` either of those.
3589
+ const attachInfo = !createWorktree ? await classifyWorktree(root) : null;
3590
+ if (!form) return;
3591
+ const isLinkedAttach = attachInfo?.isLinked === true;
3592
+ const effectiveProjectPath = isLinkedAttach ? attachInfo!.mainRoot : projectPath;
3593
+
3594
+ // Branch / cmd values used for the per-window state record —
3595
+ // `branchName` only exists in the worktree-create flow above; for
3596
+ // an attached linked worktree we report its checked-out branch;
3597
+ // for the shared-worktree / non-git case we leave it blank.
2721
3598
  const reportedBranch = createWorktree
2722
3599
  ? (branchInput || sessionName)
2723
- : "";
3600
+ : (isLinkedAttach ? attachInfo!.branch : "");
2724
3601
 
2725
3602
  // Append the user-effective values to per-field input
2726
3603
  // history so ↑/↓ can recall them on the next form open.
@@ -2729,16 +3606,106 @@ async function submitForm(): Promise<void> {
2729
3606
  if (cmd) appendHistory("cmd", cmd);
2730
3607
  if (createWorktree) appendHistory("branch", reportedBranch);
2731
3608
 
2732
- pendingNewSession = {
2733
- label: sessionName,
2734
- branch: reportedBranch,
2735
- cmd,
2736
- root,
2737
- projectPath,
2738
- sharedWorktree: !createWorktree,
2739
- };
2740
3609
  closeForm();
2741
- editor.createWindow(root, sessionName);
3610
+
3611
+ // Spawn the new window + agent terminal atomically. Compared to
3612
+ // the legacy `createWindow → window_created hook → createTerminal`
3613
+ // chain this avoids the transient `[No Name]` tab the host's
3614
+ // eager seed used to leave alongside the agent terminal: the
3615
+ // terminal IS the new window's seed buffer, so the window is
3616
+ // born with a single tab.
3617
+ const argv = splitAgentCmd(cmd);
3618
+ // Shared only when we neither created a worktree nor attached to an
3619
+ // existing linked one (i.e. a non-git dir or the repo's main tree).
3620
+ const sharedWorktree = !createWorktree && !isLinkedAttach;
3621
+ try {
3622
+ const result = await editor.createWindowWithTerminal({
3623
+ root,
3624
+ label: sessionName,
3625
+ cwd: root,
3626
+ command: argv.length > 0 ? argv : undefined,
3627
+ title: argv.length > 0 ? argv[0] : undefined,
3628
+ });
3629
+ const id = result.windowId;
3630
+ // `createWindowWithTerminal` already dove into the new window,
3631
+ // so `setWindowState` writes to it.
3632
+ editor.setWindowState("project_path", effectiveProjectPath);
3633
+ editor.setWindowState("shared_worktree", sharedWorktree);
3634
+ // If we attached to a worktree that was sitting in the picker as
3635
+ // a discovered row, drop that placeholder — this live window
3636
+ // supersedes it.
3637
+ const discId = discoveredIdByPath.get(root);
3638
+ if (discId !== undefined) {
3639
+ orchestratorSessions.delete(discId);
3640
+ discoveredIdByPath.delete(root);
3641
+ }
3642
+ const tracked: AgentSession = {
3643
+ id,
3644
+ label: sessionName,
3645
+ root,
3646
+ projectPath: effectiveProjectPath,
3647
+ sharedWorktree,
3648
+ terminalId: result.terminalId,
3649
+ state: "running",
3650
+ createdAt: Date.now(),
3651
+ branch: reportedBranch || undefined,
3652
+ };
3653
+ orchestratorSessions.set(id, tracked);
3654
+ } catch (e) {
3655
+ editor.setStatus(
3656
+ `Orchestrator: failed to start session — ${
3657
+ e instanceof Error ? e.message : String(e)
3658
+ }`,
3659
+ );
3660
+ }
3661
+ }
3662
+
3663
+ /// Open a session in an existing worktree without creating one —
3664
+ /// the dive action for a discovered row, and the building block the
3665
+ /// new-session form reuses when the user points Project Path at an
3666
+ /// existing linked worktree. Spawns a bare terminal (no agent
3667
+ /// command) rooted at the worktree, tags the window with its
3668
+ /// canonical project + `shared_worktree = false` so Archive / Delete
3669
+ /// manage it as the real worktree it is, then drops the discovered
3670
+ /// placeholder (the live window supersedes it).
3671
+ async function attachToWorktree(opts: {
3672
+ root: string;
3673
+ projectPath: string;
3674
+ label: string;
3675
+ branch?: string;
3676
+ discoveredId?: number;
3677
+ }): Promise<void> {
3678
+ try {
3679
+ const result = await editor.createWindowWithTerminal({
3680
+ root: opts.root,
3681
+ label: opts.label,
3682
+ cwd: opts.root,
3683
+ });
3684
+ const id = result.windowId;
3685
+ editor.setWindowState("project_path", opts.projectPath);
3686
+ editor.setWindowState("shared_worktree", false);
3687
+ if (opts.discoveredId !== undefined) {
3688
+ orchestratorSessions.delete(opts.discoveredId);
3689
+ discoveredIdByPath.delete(opts.root);
3690
+ }
3691
+ orchestratorSessions.set(id, {
3692
+ id,
3693
+ label: opts.label,
3694
+ root: opts.root,
3695
+ projectPath: opts.projectPath,
3696
+ sharedWorktree: false,
3697
+ terminalId: result.terminalId,
3698
+ state: "running",
3699
+ createdAt: Date.now(),
3700
+ branch: opts.branch,
3701
+ });
3702
+ } catch (e) {
3703
+ editor.setStatus(
3704
+ `Orchestrator: failed to attach session — ${
3705
+ e instanceof Error ? e.message : String(e)
3706
+ }`,
3707
+ );
3708
+ }
2742
3709
  }
2743
3710
 
2744
3711
  function startNewSession(): void {
@@ -2969,7 +3936,27 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
2969
3936
  }
2970
3937
  }
2971
3938
  }
2972
- openDialog.pendingConfirm = { action, sessionId: id };
3939
+ openDialog.pendingConfirm = { action, ids: [id] };
3940
+ openPanel.update(buildOpenSpec());
3941
+ openPanel.setFocusKey("confirm-cancel");
3942
+ }
3943
+
3944
+ // Open the confirm panel for a *bulk* action over the current
3945
+ // checkbox selection. Filters to the eligible members up front (so
3946
+ // the confirm count matches what will actually run); refuses with a
3947
+ // banner when nothing is eligible.
3948
+ function enterBulkConfirm(action: BulkAction): void {
3949
+ if (!openDialog || !openPanel) return;
3950
+ const targets = eligibleSelected(action);
3951
+ if (targets.length === 0) {
3952
+ setDialogError(`no selected session can be ${action === "stop" ? "stopped" : action + "d"}`);
3953
+ refreshOpenDialog();
3954
+ return;
3955
+ }
3956
+ // All three actions confirm — even Stop, so a bulk Stop over a
3957
+ // large selection isn't a single mis-key away. The confirm panel
3958
+ // lists the targets and shows the eligible count.
3959
+ openDialog.pendingConfirm = { action, ids: targets };
2973
3960
  openPanel.update(buildOpenSpec());
2974
3961
  openPanel.setFocusKey("confirm-cancel");
2975
3962
  }
@@ -3128,8 +4115,11 @@ editor.on("widget_event", (e) => {
3128
4115
  // on the button. Snap focus back to Visit so the user can
3129
4116
  // press Enter to open the newly-highlighted session — the
3130
4117
  // dialog's whole reason for being. Idempotent when focus
3131
- // is already on Visit.
3132
- openPanel.setFocusKey("visit");
4118
+ // is already on Visit. Skipped in bulk mode and during a
4119
+ // confirm, where "visit" isn't in the spec.
4120
+ if (selectedSessions().length < 2 && !openDialog.pendingConfirm) {
4121
+ openPanel.setFocusKey("visit");
4122
+ }
3133
4123
  }
3134
4124
  return;
3135
4125
  }
@@ -3138,6 +4128,20 @@ editor.on("widget_event", (e) => {
3138
4128
  (e.widget_key === "sessions" || e.widget_key === "visit")
3139
4129
  ) {
3140
4130
  const id = openDialog.filteredIds[openDialog.selectedIndex];
4131
+ const sel = typeof id === "number" ? orchestratorSessions.get(id) : undefined;
4132
+ if (sel && sel.discovered) {
4133
+ // Discovered worktree: there's no window to switch to —
4134
+ // open one by attaching a fresh session to the worktree.
4135
+ closeOpenDialog();
4136
+ void attachToWorktree({
4137
+ root: sel.root,
4138
+ projectPath: sel.projectPath ?? sel.root,
4139
+ label: sel.label,
4140
+ branch: sel.branch,
4141
+ discoveredId: sel.id,
4142
+ });
4143
+ return;
4144
+ }
3141
4145
  if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
3142
4146
  editor.setActiveWindow(id);
3143
4147
  }
@@ -3149,11 +4153,21 @@ editor.on("widget_event", (e) => {
3149
4153
  openForm({ fromPicker: true });
3150
4154
  return;
3151
4155
  }
4156
+ if (e.event_type === "activate" && e.widget_key === "scope-toggle") {
4157
+ toggleScope();
4158
+ return;
4159
+ }
3152
4160
  if (e.event_type === "activate" && e.widget_key === "toggle-details") {
3153
4161
  openDialog.showDetails = !openDialog.showDetails;
3154
4162
  refreshOpenDialog();
3155
4163
  return;
3156
4164
  }
4165
+ if (e.event_type === "toggle" && e.widget_key === "worktree-show") {
4166
+ // The toggle widget reports the new checked state; route through
4167
+ // the shared flip so the Alt+T chord and the click stay in sync.
4168
+ toggleShowWorktrees();
4169
+ return;
4170
+ }
3157
4171
  if (e.event_type === "activate" && e.widget_key === "stop") {
3158
4172
  enterConfirm("stop");
3159
4173
  return;
@@ -3166,44 +4180,47 @@ editor.on("widget_event", (e) => {
3166
4180
  enterConfirm("delete");
3167
4181
  return;
3168
4182
  }
3169
- if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
3170
- openDialog.pendingConfirm = null;
3171
- openPanel.update(buildOpenSpec());
4183
+ // Bulk action bar (Layout B) Stop / Archive / Delete over the
4184
+ // checkbox selection, plus Clear.
4185
+ if (e.event_type === "activate" && e.widget_key === "bulk-stop") {
4186
+ enterBulkConfirm("stop");
3172
4187
  return;
3173
4188
  }
3174
- if (e.event_type === "activate" && e.widget_key === "confirm-stop") {
3175
- openDialog.pendingConfirm = null;
3176
- stopSelectedSession();
3177
- if (openPanel) openPanel.update(buildOpenSpec());
4189
+ if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
4190
+ enterBulkConfirm("archive");
3178
4191
  return;
3179
4192
  }
3180
- if (e.event_type === "activate" && e.widget_key === "confirm-archive") {
3181
- const id = openDialog.filteredIds[openDialog.selectedIndex];
3182
- openDialog.pendingConfirm = null;
3183
- // Mark the session in-flight so the preview swaps to
3184
- // "Archiving…" and its action buttons disappear until git
3185
- // finishes. The row stays in the list — `editor.listWindows()`
3186
- // is still the source of truth and will drop it on
3187
- // `closeWindow`, which is intentional: a slightly-laggy real
3188
- // state beats a synchronously faked one that can desync from
3189
- // git reality (e.g. when `git worktree move` fails).
3190
- if (typeof id === "number" && id > 0) {
3191
- openDialog.inFlight = { action: "archive", sessionId: id };
3192
- }
3193
- void archiveSelectedSession(id);
4193
+ if (e.event_type === "activate" && e.widget_key === "bulk-delete") {
4194
+ enterBulkConfirm("delete");
4195
+ return;
4196
+ }
4197
+ if (e.event_type === "activate" && e.widget_key === "bulk-clear") {
4198
+ openDialog.selectedIds.clear();
3194
4199
  refreshOpenDialog();
4200
+ openPanel.setFocusKey("visit");
3195
4201
  return;
3196
4202
  }
3197
- if (e.event_type === "activate" && e.widget_key === "confirm-delete") {
3198
- const id = openDialog.pendingConfirm?.sessionId;
3199
- // Mark in-flight — see comment on confirm-archive above.
3200
- // `deleteConfirmedSession` clears `pendingConfirm` itself, so
3201
- // we capture the id here before it goes away.
3202
- if (typeof id === "number" && id > 0) {
3203
- openDialog.inFlight = { action: "delete", sessionId: id };
4203
+ if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
4204
+ openDialog.pendingConfirm = null;
4205
+ openPanel.update(buildOpenSpec());
4206
+ return;
4207
+ }
4208
+ // Confirmed Stop / Archive / Delete single row or bulk batch.
4209
+ // The ids were captured into `pendingConfirm` by enterConfirm /
4210
+ // enterBulkConfirm; `runConfirmedAction` re-checks eligibility,
4211
+ // drives the in-flight markers, and triggers sync.
4212
+ if (
4213
+ e.event_type === "activate" &&
4214
+ (e.widget_key === "confirm-stop" ||
4215
+ e.widget_key === "confirm-archive" ||
4216
+ e.widget_key === "confirm-delete")
4217
+ ) {
4218
+ const confirm = openDialog.pendingConfirm;
4219
+ openDialog.pendingConfirm = null;
4220
+ if (confirm) {
4221
+ void runConfirmedAction(confirm.action, confirm.ids);
3204
4222
  }
3205
- void deleteConfirmedSession();
3206
- refreshOpenDialog();
4223
+ if (openPanel) openPanel.update(buildOpenSpec());
3207
4224
  return;
3208
4225
  }
3209
4226
  if (e.event_type === "cancel") {
@@ -3262,57 +4279,13 @@ function killSelected(): void {
3262
4279
  // Lifecycle hook handlers
3263
4280
  // =============================================================================
3264
4281
 
3265
- editor.on("window_created", async (payload) => {
3266
- const id = payload.id;
3267
- if (
3268
- pendingNewSession &&
3269
- payload.label === pendingNewSession.label
3270
- ) {
3271
- const intent = pendingNewSession;
3272
- pendingNewSession = null;
3273
- // Dive into the new session FIRST so its terminal_manager is
3274
- // the editor-active one. Subsequent `createTerminal` /
3275
- // `sendTerminalInput` calls then resolve against the new
3276
- // session's window without needing a cross-window terminal
3277
- // lookup. Creating a session is a visit-now action anyway —
3278
- // the dive isn't user-visible flicker, it's the desired
3279
- // landing state.
3280
- editor.setActiveWindow(id);
3281
- // Record the new session's project_path / shared_worktree
3282
- // into per-window plugin state — these survive editor
3283
- // restarts via `orchestrator_persistence.rs`, and feed the
3284
- // Open dialog's "this project" filter on the next launch.
3285
- // `setWindowState` writes to the active window, which we
3286
- // just set above.
3287
- editor.setWindowState("project_path", intent.projectPath);
3288
- editor.setWindowState("shared_worktree", intent.sharedWorktree);
3289
- // When the user provided a non-empty agent command, spawn it as
3290
- // the PTY child directly (no shell middleman). Tab title reads
3291
- // the command name ("python3", "claude", ...) instead of the
3292
- // generic "*Terminal N*". When `cmd` is empty the host picks
3293
- // the user's shell as before.
3294
- const argv = splitAgentCmd(intent.cmd);
3295
- const term = await editor.createTerminal({
3296
- cwd: intent.root,
3297
- focus: false,
3298
- command: argv.length > 0 ? argv : undefined,
3299
- title: argv.length > 0 ? argv[0] : undefined,
3300
- });
3301
- const tracked: AgentSession = {
3302
- id,
3303
- label: intent.label,
3304
- root: intent.root,
3305
- terminalId: term.terminalId,
3306
- state: "running",
3307
- createdAt: Date.now(),
3308
- };
3309
- orchestratorSessions.set(id, tracked);
3310
- // Legacy `sendTerminalInput` path is no longer needed when the
3311
- // command is spawned directly. Kept for the shell-only case
3312
- // would be `editor.sendTerminalInput(term.terminalId, "\n")` to
3313
- // wake up the prompt, but that's unnecessary — the shell prints
3314
- // its own prompt on startup.
3315
- }
4282
+ editor.on("window_created", () => {
4283
+ // The orchestrator's own new-session flow uses
4284
+ // `createWindowWithTerminal` (atomic — populates the window
4285
+ // before returning), so by the time this hook fires for one of
4286
+ // our spawns the session is already tracked. Other plugins or
4287
+ // host actions creating windows just need the picker to
4288
+ // refresh.
3316
4289
  refreshOpenDialog();
3317
4290
  });
3318
4291
 
@@ -3336,9 +4309,8 @@ editor.on("active_window_changed", () => {
3336
4309
  // viewport at the same time.
3337
4310
  editor.on("resize", () => {
3338
4311
  if (openDialog && openPanel) {
3339
- const listVisibleRows = openListVisibleRows();
3340
- openDialog.listVisibleRows = listVisibleRows;
3341
- openDialog.embedRows = Math.max(3, listVisibleRows - 5);
4312
+ // buildOpenSpec refits `listVisibleRows` to the session count
4313
+ // (bounded by the new screen budget) on the refresh below.
3342
4314
  refreshOpenDialog();
3343
4315
  }
3344
4316
  });