@fresh-editor/fresh-editor 0.3.9 → 0.3.10

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.
@@ -269,6 +269,14 @@ interface OpenDialogState {
269
269
  // (worktrees hidden) — discovery is opt-in. Remembered across opens
270
270
  // via `lastShowWorktrees`.
271
271
  showWorktrees: boolean;
272
+ // `true` hides "trivial" sessions — those with no terminal and at
273
+ // most one open file/buffer (empty-unnamed-buffer and single-file
274
+ // shells left behind by one-off editor launches). The "Show
275
+ // empty/1-file sessions" checkbox (Alt+I / `orchestrator_toggle_trivial`)
276
+ // flips it. Defaults to true; remembered across opens via
277
+ // `lastHideTrivial`. The active session and discovered worktree rows
278
+ // are never hidden by this filter regardless of the flag.
279
+ hideTrivial: boolean;
272
280
  // Progress marker for an in-flight *bulk* action. While set, the
273
281
  // selection bar shows "Archiving 2/3…" and its buttons are
274
282
  // hidden so a second Enter can't re-fire mid-batch. Cleared when
@@ -333,6 +341,87 @@ let lastOpenScope: "current" | "all" = "all";
333
341
  // (worktrees hidden) — surfacing them is opt-in via "Show all
334
342
  // worktrees" (Alt+T).
335
343
  let lastShowWorktrees = false;
344
+ // Remembered across opens: whether "trivial" sessions are hidden.
345
+ // Defaults to true — every editor launch on a throwaway directory or a
346
+ // single file leaves a workspace file behind, which restores as a shell
347
+ // window and clutters the list. Hiding them by default keeps the picker
348
+ // focused on real sessions; the "Show empty/1-file sessions" checkbox
349
+ // (Alt+I) reveals them.
350
+ let lastHideTrivial = true;
351
+
352
+ // Per-session content summary keyed by canonical session root, built
353
+ // from the on-disk workspace files. The restored shell windows don't
354
+ // carry their open-tab layout (it's lazily re-warmed on first dive), so
355
+ // the workspace file is the only place to learn how much a session
356
+ // holds. Rebuilt each time the picker opens. A session is "trivial"
357
+ // when it has no terminal and at most one real file/unnamed buffer —
358
+ // the empty-unnamed-buffer and single-file cases the filter targets.
359
+ interface SessionContent {
360
+ files: number;
361
+ hasTerminal: boolean;
362
+ trivial: boolean;
363
+ }
364
+ const sessionContentByRoot = new Map<string, SessionContent>();
365
+
366
+ // Roots from the editor (`WindowInfo.root`) and from workspace files
367
+ // (`working_dir`) are both canonical absolute paths, but normalise a
368
+ // trailing slash so the two always key the same map entry.
369
+ function normRoot(p: string): string {
370
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
371
+ }
372
+
373
+ // Scan `<dataDir>/workspaces/*.json` and summarise each session's open
374
+ // content. Mirrors the host's own `discover_sessions` (which keys on the
375
+ // file's `working_dir`), so a root matches regardless of how the
376
+ // filename was percent-encoded. Best-effort: unreadable / unparseable
377
+ // files are skipped, and a missing summary is treated as "not trivial"
378
+ // (shown) by the filter, so we never hide a session we couldn't classify.
379
+ function scanSessionContent(): void {
380
+ sessionContentByRoot.clear();
381
+ const dir = editor.pathJoin(editor.getDataDir(), "workspaces");
382
+ let entries: DirEntry[];
383
+ try {
384
+ entries = editor.readDir(dir);
385
+ } catch {
386
+ return;
387
+ }
388
+ if (!entries) return;
389
+ for (const e of entries) {
390
+ if (!e.is_file || !e.name.endsWith(".json")) continue;
391
+ const raw = editor.readFile(editor.pathJoin(dir, e.name));
392
+ if (!raw) continue;
393
+ let ws: Record<string, unknown>;
394
+ try {
395
+ ws = JSON.parse(raw);
396
+ } catch {
397
+ continue;
398
+ }
399
+ const wd = ws["working_dir"];
400
+ if (typeof wd !== "string") continue;
401
+ let files = 0;
402
+ let hasTerminal = Array.isArray(ws["terminals"]) &&
403
+ (ws["terminals"] as unknown[]).length > 0;
404
+ const splits = ws["split_states"];
405
+ if (splits && typeof splits === "object") {
406
+ for (const sv of Object.values(splits as Record<string, unknown>)) {
407
+ const tabs = (sv as Record<string, unknown> | null)?.["open_tabs"];
408
+ if (!Array.isArray(tabs)) continue;
409
+ for (const t of tabs) {
410
+ if (t && typeof t === "object") {
411
+ if ("File" in t || "Unnamed" in t) files++;
412
+ else if ("Terminal" in t) hasTerminal = true;
413
+ }
414
+ }
415
+ }
416
+ }
417
+ sessionContentByRoot.set(normRoot(wd), {
418
+ files,
419
+ hasTerminal,
420
+ trivial: !hasTerminal && files <= 1,
421
+ });
422
+ }
423
+ }
424
+
336
425
  const OPEN_MODE = "orchestrator-open";
337
426
 
338
427
  // =============================================================================
@@ -561,6 +650,7 @@ function filterSessions(needle: string): number[] {
561
650
  reconcileSessions();
562
651
  const scope = openDialog?.scope ?? "current";
563
652
  const showWorktrees = openDialog?.showWorktrees ?? false;
653
+ const hideTrivial = openDialog?.hideTrivial ?? false;
564
654
  const cur = currentProjectKey();
565
655
  let allIds = Array.from(orchestratorSessions.keys());
566
656
  // "Show all worktrees" is opt-in: by default the discovered on-disk
@@ -568,6 +658,20 @@ function filterSessions(needle: string): number[] {
568
658
  if (!showWorktrees) {
569
659
  allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
570
660
  }
661
+ // "Hide empty/1-file sessions": drop the restored shells that hold no
662
+ // real work. The active session is always kept (you must be able to
663
+ // see where you are), and discovered worktree rows are governed by
664
+ // their own toggle, not this one. A session with no summary (e.g. a
665
+ // freshly created agent session not yet written to disk) is kept too.
666
+ if (hideTrivial) {
667
+ const activeId = editor.activeWindow();
668
+ allIds = allIds.filter((id) => {
669
+ const s = orchestratorSessions.get(id)!;
670
+ if (s.discovered || id === activeId) return true;
671
+ const c = sessionContentByRoot.get(normRoot(s.root));
672
+ return !c || !c.trivial;
673
+ });
674
+ }
571
675
 
572
676
  const isDisc = (id: number): number =>
573
677
  orchestratorSessions.get(id)!.discovered ? 1 : 0;
@@ -650,7 +754,7 @@ function sessionsColumnHeader(): WidgetSpec {
650
754
  }
651
755
 
652
756
  // Build one rendered list-item row for `id`:
653
- // `[ ] ` <name + BASE/⇄ badges + on-disk tag> <project basename>
757
+ // `[ ] ` <name + on-disk tag> <project basename>
654
758
  // The active session's name renders bold; discovered (on-disk,
655
759
  // unopened) worktrees render dim with a `· on-disk` tag instead of a
656
760
  // glyph. The project column is filled only for sessions that don't
@@ -661,7 +765,6 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
661
765
  return styledRow([{ text: "(unknown)" }]);
662
766
  }
663
767
  const isActive = id === activeId;
664
- const isBase = id === 1;
665
768
  const isDiscovered = !!s.discovered;
666
769
  const isChecked = openDialog?.selectedIds.has(id) ?? false;
667
770
 
@@ -686,17 +789,9 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
686
789
  : undefined,
687
790
  },
688
791
  ];
689
- // Visible width of the NAME column so far (label + badges), used
792
+ // Visible width of the NAME column so far (label + tags), used
690
793
  // to pad out to LIST_NAME_W before the PROJECT column.
691
794
  let nameWidth = s.label.length;
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
795
  if (isDiscovered) {
701
796
  entries.push({
702
797
  text: " · on-disk",
@@ -737,13 +832,7 @@ function buildPreviewEntries(
737
832
  }
738
833
  const activeId = editor.activeWindow();
739
834
  const isActive = s.id === activeId;
740
- const isBase = s.id === 1;
741
835
  const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
742
- // Count siblings sharing the same `root`. The set includes
743
- // `s` itself; `> 1` means at least one other session lives at
744
- // the same path (shared-worktree mode, or two sessions
745
- // explicitly aimed at the same directory).
746
- const sharedCount = countSiblingsAtRoot(s.root);
747
836
  const headerEntries: { text: string; style?: Record<string, unknown> }[] = [
748
837
  {
749
838
  text: stateText,
@@ -754,40 +843,13 @@ function buildPreviewEntries(
754
843
  { text: " " },
755
844
  { text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
756
845
  ];
757
- if (isBase) {
758
- // BASE badge in the preview the long-form counterpart to
759
- // the list-row badge, with an inline explanation so the user
760
- // doesn't have to wonder why Stop / Archive / Delete are
761
- // greyed out.
846
+ if (!s.discovered && !ownsWorktree(s)) {
847
+ // In-place / launch session: runs inside a real checkout, owns no
848
+ // dedicated worktree. Surfaced so the user knows Archive doesn't
849
+ // apply (Delete just forgets it, leaving the directory untouched).
762
850
  headerEntries.push(
763
851
  { text: " " },
764
- {
765
- text: "BASE",
766
- style: { fg: "ui.help_key_fg", bold: true },
767
- },
768
- { text: " — editor session", style: { fg: "ui.menu_disabled_fg", italic: true } },
769
- );
770
- }
771
- if (sharedCount > 1) {
772
- headerEntries.push(
773
- { text: " " },
774
- {
775
- text: `SHARED ×${sharedCount}`,
776
- style: { fg: "ui.status_error_indicator_fg", bold: true },
777
- },
778
- );
779
- } else if (s.sharedWorktree) {
780
- // Single-session shared-worktree mode (the user opted out of
781
- // a dedicated worktree even though no second session is on
782
- // this root yet). Still worth surfacing so the user knows
783
- // why Archive / Delete refuse to run a `git worktree
784
- // remove` here.
785
- headerEntries.push(
786
- { text: " " },
787
- {
788
- text: "SHARED",
789
- style: { fg: "ui.menu_disabled_fg", italic: true },
790
- },
852
+ { text: "in-place", style: { fg: "ui.menu_disabled_fg", italic: true } },
791
853
  );
792
854
  }
793
855
  return [
@@ -798,16 +860,15 @@ function buildPreviewEntries(
798
860
  ];
799
861
  }
800
862
 
801
- /// Return the number of orchestrator sessions whose `root`
802
- /// equals `root`. Used to surface "SHARED ×N" in the preview
803
- /// pane and to refuse Archive / Delete on a shared root
804
- /// while another session still lives there.
805
- function countSiblingsAtRoot(root: string): number {
806
- let n = 0;
807
- for (const s of orchestratorSessions.values()) {
808
- if (s.root === root) n += 1;
809
- }
810
- return n;
863
+ // A session "owns" a removable git worktree when it was created as a
864
+ // dedicated `git worktree add` (project path set, not a shared/in-place
865
+ // root) or was discovered on disk via `git worktree list`. Only these
866
+ // have a worktree to `git worktree remove`/`move`. The launch session
867
+ // (the dir the editor was started in) and in-place sessions run inside
868
+ // a real checkout, so Archive (which moves the worktree) doesn't apply
869
+ // and Delete simply forgets the session without touching the directory.
870
+ function ownsWorktree(s: AgentSession): boolean {
871
+ return !!s.discovered || (!!s.projectPath && !s.sharedWorktree);
811
872
  }
812
873
 
813
874
  // =============================================================================
@@ -856,11 +917,15 @@ function selectedSessions(): number[] {
856
917
  function bulkEligible(action: BulkAction, id: number): boolean {
857
918
  const s = orchestratorSessions.get(id);
858
919
  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;
920
+ // Stop kills the agent process group — only meaningful for a live
921
+ // session that actually spawned one (never the launch session, which
922
+ // has no agent terminal, so signalling it can't touch the editor).
923
+ if (action === "stop") return !s.discovered && id > 0 && !!s.terminalId;
924
+ // Delete forgets any session. When it owns a worktree the worktree is
925
+ // removed too; otherwise (launch/in-place) it's just dropped.
926
+ if (action === "delete") return id > 0 || !!s.discovered;
927
+ // Archive moves the worktree to the graveyard, so it needs one.
928
+ return ownsWorktree(s);
864
929
  }
865
930
 
866
931
  function eligibleSelected(action: BulkAction): number[] {
@@ -899,9 +964,9 @@ function maxListRowsForScreen(): number {
899
964
  // Chrome that isn't list rows: panel borders (2) + title (1) +
900
965
  // spacer (1) + footer (1) + sessions-section borders (2) +
901
966
  // 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);
967
+ // Trivial-filter + Filter + separator + header = 7) = 14. Floor at
968
+ // MIN_LIST_ROWS so a tiny terminal still shows something.
969
+ return Math.max(MIN_LIST_ROWS, panelH - 14);
905
970
  }
906
971
 
907
972
  // Compose the right-hand preview pane. Normally it shows info
@@ -961,13 +1026,13 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
961
1026
  // Match the sessions column's content height so the two panes'
962
1027
  // bottom borders land on the same row. Sessions column inside its
963
1028
  // 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;
1029
+ // Trivial-filter (1) + Filter (1) + separator (1) + header (1) +
1030
+ // list (listVisibleRows) = listVisibleRows + 7. Preview inside its
1031
+ // borders = button row (1) + spacer (1) + embedRows, so embedRows
1032
+ // must equal listVisibleRows + 5. When details ARE shown, two info
1033
+ // rows + a spacer eat three more lines — `_DETAILS_CHROME_ROWS`
1034
+ // accounts for that.
1035
+ const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 5;
971
1036
  const detailsOn = openDialog?.showDetails ?? false;
972
1037
  const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
973
1038
  const embedRows = Math.max(
@@ -1050,17 +1115,18 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
1050
1115
  // same conditions that `stopSelectedSession`, `enterConfirm`,
1051
1116
  // and the lifecycle handlers already check internally.
1052
1117
  //
1053
- // * Stop: refused on the base session (id 1).
1054
- // * Archive / Delete: also refused on the base session, plus
1055
- // when this session shares its worktree with the project
1056
- // root (no `git worktree` entry to remove) or shares a root
1057
- // with other live sessions (would yank disk out from
1058
- // under them).
1059
- const isBase = s.id === 1;
1060
- const siblings = countSiblingsAtRoot(s.root);
1061
- const sharesRoot = siblings > 1 || s.sharedWorktree;
1062
- const stopDisabled = isBase;
1063
- const lifecycleDisabled = isBase || sharesRoot;
1118
+ // * Stop: only a live session with an agent terminal can be
1119
+ // stopped (the launch session has none).
1120
+ // * Archive: needs an owned worktree to move to the graveyard.
1121
+ // * Delete: forgets the session, removing the worktree only when one
1122
+ // is owned (otherwise the directory is left untouched).
1123
+ // * Archive/Delete are both refused on the last live window — the
1124
+ // editor must always host at least one.
1125
+ const hasWorktree = ownsWorktree(s);
1126
+ const isLastWindow = s.id > 0 && liveWindowCount() <= 1;
1127
+ const stopDisabled = s.discovered || !s.terminalId;
1128
+ const archiveDisabled = !hasWorktree || isLastWindow;
1129
+ const deleteDisabled = isLastWindow;
1064
1130
  const buttonRow = row(
1065
1131
  button("Visit", { intent: "primary", key: "visit" }),
1066
1132
  spacer(2),
@@ -1069,12 +1135,12 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
1069
1135
  spacer(2),
1070
1136
  button("Stop", { key: "stop", disabled: stopDisabled }),
1071
1137
  spacer(2),
1072
- button("Archive", { key: "archive", disabled: lifecycleDisabled }),
1138
+ button("Archive", { key: "archive", disabled: archiveDisabled }),
1073
1139
  spacer(2),
1074
1140
  button("Delete", {
1075
1141
  intent: "danger",
1076
1142
  key: "delete",
1077
- disabled: lifecycleDisabled,
1143
+ disabled: deleteDisabled,
1078
1144
  }),
1079
1145
  );
1080
1146
  const embedWidget = windowEmbed({
@@ -1091,13 +1157,12 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
1091
1157
  embedWidget,
1092
1158
  )
1093
1159
  : col(buttonRow, spacer(0), embedWidget);
1094
- // Surface BASE in the preview section label so it's always visible
1095
- // (the list-row badge gets truncated at 25% column width). The
1096
- // base session is the editor process itselfclosing or moving
1097
- // its worktree would close the editor / break the user's current
1098
- // tree, so Stop / Archive / Delete refuse against it.
1099
- const sectionLabel = isBase
1100
- ? `${s.label} — BASE (editor session)`
1160
+ // Surface the launch session in the preview label so it's always
1161
+ // visible (the list-row badge gets truncated at 25% column width).
1162
+ // It's the dir the editor was started in informational only; it's
1163
+ // deletable like any other session once another window exists.
1164
+ const sectionLabel = s.id === 1
1165
+ ? `${s.label} — launch session`
1101
1166
  : s.label;
1102
1167
  return labeledSection({
1103
1168
  label: sectionLabel,
@@ -1270,14 +1335,9 @@ function buildBulkPane(): WidgetSpec {
1270
1335
  const items: TextPropertyEntry[] = sel.map((id) => {
1271
1336
  const ss = orchestratorSessions.get(id)!;
1272
1337
  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)) {
1338
+ if (!ss.discovered && !ownsWorktree(ss)) {
1279
1339
  rowParts.push({
1280
- text: " · shared worktree",
1340
+ text: " · in-place (forgotten, not removed)",
1281
1341
  style: { fg: "ui.menu_disabled_fg", italic: true },
1282
1342
  });
1283
1343
  } else if (ss.discovered) {
@@ -1435,6 +1495,23 @@ function buildOpenSpec(): WidgetSpec {
1435
1495
  }),
1436
1496
  flexSpacer(),
1437
1497
  );
1498
+ // Content filter checkbox, beneath the worktree one. The flag is
1499
+ // `hideTrivial`, but the checkbox reads as an opt-in "show" toggle to
1500
+ // match the worktree row: unchecked (default) hides the empty /
1501
+ // single-file shells, checking it reveals them. Inert during confirm.
1502
+ const trivialKey = editor.getKeybindingLabel(
1503
+ "orchestrator_toggle_trivial",
1504
+ OPEN_MODE,
1505
+ );
1506
+ const trivialLabel = trivialKey
1507
+ ? `Show empty/1-file sessions (${trivialKey})`
1508
+ : "Show empty/1-file sessions";
1509
+ const trivialFilterRow = row(
1510
+ toggle(!openDialog.hideTrivial, trivialLabel, {
1511
+ key: openDialog.pendingConfirm !== null ? undefined : "hide-trivial",
1512
+ }),
1513
+ flexSpacer(),
1514
+ );
1438
1515
 
1439
1516
  return col(
1440
1517
  {
@@ -1490,6 +1567,7 @@ function buildOpenSpec(): WidgetSpec {
1490
1567
  ),
1491
1568
  projectControlRow,
1492
1569
  worktreeFilterRow,
1570
+ trivialFilterRow,
1493
1571
  filterInput,
1494
1572
  sessionsSeparator(),
1495
1573
  sessionsColumnHeader(),
@@ -1617,6 +1695,9 @@ function refreshOpenDialog(): void {
1617
1695
  function openControlRoom(): void {
1618
1696
  if (openPanel) return;
1619
1697
  reconcileSessions();
1698
+ // Summarise on-disk session content up front so the trivial filter
1699
+ // has data on the first render.
1700
+ scanSessionContent();
1620
1701
  const activeId = editor.activeWindow();
1621
1702
  // Seed with the screen-max; buildOpenSpec refits to the session
1622
1703
  // count on the first render (and every render after).
@@ -1637,6 +1718,7 @@ function openControlRoom(): void {
1637
1718
  scope: lastOpenScope,
1638
1719
  selectedIds: new Set<number>(),
1639
1720
  showWorktrees: lastShowWorktrees,
1721
+ hideTrivial: lastHideTrivial,
1640
1722
  bulkInFlight: null,
1641
1723
  };
1642
1724
  openDialog.filteredIds = filterSessions("");
@@ -1686,7 +1768,7 @@ function closeOpenDialog(): void {
1686
1768
  // live window).
1687
1769
  function stopOne(id: number): boolean {
1688
1770
  const s = orchestratorSessions.get(id);
1689
- if (!s || id <= 0 || id === 1 || s.discovered) return false;
1771
+ if (!s || id <= 0 || s.discovered || !s.terminalId) return false;
1690
1772
  editor.signalWindow(id, "SIGTERM");
1691
1773
  // SIGKILL fallback for agents that ignore SIGTERM. The host's
1692
1774
  // signalWindow is idempotent on already-exited process groups, so
@@ -1776,7 +1858,21 @@ function pickNextActiveSession(excludeId: number): number {
1776
1858
  for (const sid of orchestratorSessions.keys()) {
1777
1859
  if (sid !== excludeId && sid > 0) return sid;
1778
1860
  }
1779
- return 1;
1861
+ // No other live window. Callers guard against closing the last
1862
+ // window before reaching here, so this is a safe no-op swap (id 1
1863
+ // is no longer guaranteed to exist — it's deletable like any other).
1864
+ return excludeId;
1865
+ }
1866
+
1867
+ // Number of real editor windows. Discovered on-disk rows have negative
1868
+ // ids and are not windows. The editor must always host at least one
1869
+ // window, so deleting/archiving the last live window is refused.
1870
+ function liveWindowCount(): number {
1871
+ let n = 0;
1872
+ for (const s of orchestratorSessions.values()) {
1873
+ if (s.id > 0) n += 1;
1874
+ }
1875
+ return n;
1780
1876
  }
1781
1877
 
1782
1878
  // Resolve the *main* repo root a session's worktree belongs to, so
@@ -1809,7 +1905,17 @@ interface LifecycleResult {
1809
1905
  async function archiveOne(id: number): Promise<LifecycleResult> {
1810
1906
  const s = orchestratorSessions.get(id);
1811
1907
  if (!s) return { ok: false, err: "session gone" };
1812
- if (id === 1) return { ok: false, err: "cannot archive the base session" };
1908
+ // Archive moves the worktree to the graveyard only sessions that
1909
+ // own one can be archived. A launch/in-place session has no separate
1910
+ // worktree, so there's nothing to move; use Delete to forget it.
1911
+ if (!ownsWorktree(s)) {
1912
+ return { ok: false, err: "no worktree to archive — use Delete to forget this session" };
1913
+ }
1914
+ // The editor must keep at least one window; closing the last one
1915
+ // would orphan the move. Refuse before touching the worktree.
1916
+ if (s.id > 0 && liveWindowCount() <= 1) {
1917
+ return { ok: false, err: "cannot archive the last window — open another session first" };
1918
+ }
1813
1919
  const repoRoot = await worktreeRepoRoot(s);
1814
1920
  if (!repoRoot) return { ok: false, err: "not a git repository" };
1815
1921
 
@@ -1820,7 +1926,7 @@ async function archiveOne(id: number): Promise<LifecycleResult> {
1820
1926
  if (id === editor.activeWindow()) {
1821
1927
  editor.setActiveWindow(pickNextActiveSession(id));
1822
1928
  }
1823
- editor.signalWindow(id, "SIGKILL");
1929
+ if (s.terminalId) editor.signalWindow(id, "SIGKILL");
1824
1930
  editor.closeWindow(id);
1825
1931
  // Brief settle so the filesystem reflects the pty's exit before
1826
1932
  // we move the worktree out from under it.
@@ -2072,52 +2178,66 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
2072
2178
  };
2073
2179
  }
2074
2180
 
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.
2181
+ // Delete a single session: close the editor session, then — only when
2182
+ // the session owns a worktree — `git worktree remove --force` to drop
2183
+ // it from disk (and prune any archive-manifest entry). A launch or
2184
+ // in-place session owns no worktree, so Delete just forgets it: the
2185
+ // window closes and the directory is left untouched (a fresh session
2186
+ // can always be opened there again). Handles discovered on-disk
2187
+ // worktrees (no window to close). Does NOT trigger sync — the caller
2188
+ // batches it.
2081
2189
  async function deleteOne(id: number): Promise<LifecycleResult> {
2082
2190
  const s = orchestratorSessions.get(id);
2083
2191
  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" };
2192
+ // The editor must keep at least one window. Refuse before any
2193
+ // close/worktree-removal so a removable session can't `git worktree
2194
+ // remove` the tree the live editor is still sitting in.
2195
+ if (s.id > 0 && liveWindowCount() <= 1) {
2196
+ return { ok: false, err: "cannot delete the last window — open another session first" };
2197
+ }
2198
+ const removable = ownsWorktree(s);
2087
2199
 
2088
2200
  if (!s.discovered && id > 0) {
2089
- // close_window refuses to close the active window, so swap away.
2201
+ // close_window refuses to close the active (and the last) window,
2202
+ // so swap away first. SIGKILL only when there's an agent terminal —
2203
+ // a launch/in-place session has none, and signalling it must never
2204
+ // touch the editor itself.
2090
2205
  if (id === editor.activeWindow()) {
2091
2206
  editor.setActiveWindow(pickNextActiveSession(id));
2092
2207
  }
2093
- editor.signalWindow(id, "SIGKILL");
2208
+ if (s.terminalId) editor.signalWindow(id, "SIGKILL");
2094
2209
  editor.closeWindow(id);
2095
- await editor.delay(250);
2210
+ if (removable) await editor.delay(250);
2096
2211
  }
2097
2212
 
2098
- // `--force` because the worktree may have unstaged changes the user
2099
- // explicitly chose to discard via the confirm step.
2100
- const removeRes = await spawnCollect(
2101
- "git",
2102
- ["-C", repoRoot, "worktree", "remove", "--force", s.root],
2103
- repoRoot,
2104
- );
2105
- if (removeRes.exit_code !== 0) {
2106
- return {
2107
- ok: false,
2108
- err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
2109
- repoRoot,
2110
- };
2111
- }
2213
+ let repoRoot: string | undefined;
2214
+ if (removable) {
2215
+ const rr = await worktreeRepoRoot(s);
2216
+ if (!rr) return { ok: false, err: "not a git repository" };
2217
+ repoRoot = rr;
2218
+ // `--force` because the worktree may have unstaged changes the user
2219
+ // explicitly chose to discard via the confirm step.
2220
+ const removeRes = await spawnCollect(
2221
+ "git",
2222
+ ["-C", rr, "worktree", "remove", "--force", s.root],
2223
+ rr,
2224
+ );
2225
+ if (removeRes.exit_code !== 0) {
2226
+ return {
2227
+ ok: false,
2228
+ err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
2229
+ repoRoot,
2230
+ };
2231
+ }
2112
2232
 
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).
2116
- const manifest = loadArchiveManifest(repoRoot);
2117
- const before = manifest.sessions.length;
2118
- manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
2119
- if (manifest.sessions.length !== before) {
2120
- saveArchiveManifest(repoRoot, manifest);
2233
+ // Drop the matching manifest entry too, in case the session was
2234
+ // already archived (delete-from-archived drops dormant sessions).
2235
+ const manifest = loadArchiveManifest(rr);
2236
+ const before = manifest.sessions.length;
2237
+ manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
2238
+ if (manifest.sessions.length !== before) {
2239
+ saveArchiveManifest(rr, manifest);
2240
+ }
2121
2241
  }
2122
2242
 
2123
2243
  if (s.discovered) {
@@ -2237,6 +2357,11 @@ editor.defineMode(
2237
2357
  // surfaces discovered on-disk worktree rows. Rebindable, same as
2238
2358
  // the scope toggle.
2239
2359
  ["M-t", "orchestrator_toggle_worktrees"],
2360
+ // Alt+I toggles "Show empty/1-file sessions" — reveals the trivial
2361
+ // restored shells hidden by default. Rebindable, same as the others.
2362
+ // (Alt+E is unavailable: it's the Edit menu's mnemonic, which the
2363
+ // menu bar claims before the picker's mode keymap sees it.)
2364
+ ["M-i", "orchestrator_toggle_trivial"],
2240
2365
  ],
2241
2366
  true,
2242
2367
  true,
@@ -2328,6 +2453,30 @@ function toggleShowWorktrees(): void {
2328
2453
 
2329
2454
  registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
2330
2455
 
2456
+ // Flip "Show empty/1-file sessions" — reveal/hide the trivial restored
2457
+ // shells. Preserves the highlighted row across the re-filter where
2458
+ // possible; drops now-hidden rows from the bulk selection. Shared by the
2459
+ // Alt+I chord and the checkbox click.
2460
+ function toggleHideTrivial(): void {
2461
+ if (!openDialog) return;
2462
+ openDialog.hideTrivial = !openDialog.hideTrivial;
2463
+ lastHideTrivial = openDialog.hideTrivial;
2464
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2465
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2466
+ // Hiding trivial rows shouldn't leave them lingering in the selection.
2467
+ if (openDialog.hideTrivial) {
2468
+ const visible = new Set(openDialog.filteredIds);
2469
+ for (const id of [...openDialog.selectedIds]) {
2470
+ if (!visible.has(id)) openDialog.selectedIds.delete(id);
2471
+ }
2472
+ }
2473
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2474
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2475
+ refreshOpenDialog();
2476
+ }
2477
+
2478
+ registerHandler("orchestrator_toggle_trivial", toggleHideTrivial);
2479
+
2331
2480
  // =============================================================================
2332
2481
  // New-session floating form
2333
2482
  // =============================================================================
@@ -3904,36 +4053,31 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
3904
4053
  if (!openDialog || !openPanel) return;
3905
4054
  const id = openDialog.filteredIds[openDialog.selectedIndex];
3906
4055
  if (typeof id !== "number" || id <= 0) return;
3907
- // Refuse Archive / Delete on a shared root while other
3908
- // sessions still live there. Both actions either move
3909
- // (`git worktree move`) or remove (`git worktree remove`)
3910
- // the on-disk path doing that under another running
3911
- // session would yank the rug out from under it. Stop is
3912
- // fine: it only signals THIS session's process group, no
3913
- // disk operation.
4056
+ // Archive moves the worktree to the graveyard, so it only applies to
4057
+ // a session that owns one. A launch/in-place session runs inside a
4058
+ // real checkout with no `git worktree` entry Archive would have
4059
+ // nothing to move (and must never rm-rf the user's actual project).
4060
+ // Delete forgets the session and removes the worktree only when one
4061
+ // is owned. Both refuse the last live window (the editor must keep at
4062
+ // least one) — surface that before the confirm step.
3914
4063
  if (action === "archive" || action === "delete") {
3915
4064
  const session = orchestratorSessions.get(id);
3916
- if (session) {
3917
- const siblings = countSiblingsAtRoot(session.root);
3918
- if (siblings > 1) {
3919
- setDialogError(
3920
- `cannot ${action} session [${id}] ${session.label} — ${siblings - 1} other session(s) share this worktree; close them first`,
3921
- );
3922
- refreshOpenDialog();
3923
- return;
3924
- }
3925
- if (session.sharedWorktree) {
3926
- // Single-session shared-worktree mode: there's no
3927
- // `git worktree` entry to remove for this session.
3928
- // Block both lifecycle actions so we don't run
3929
- // `git worktree remove` against a non-worktree path
3930
- // and rm-rf the user's actual project directory.
3931
- setDialogError(
3932
- `cannot ${action} session [${id}] ${session.label} — session shares its working tree with the project root; close it via the editor instead`,
3933
- );
3934
- refreshOpenDialog();
3935
- return;
3936
- }
4065
+ if (session && session.id > 0 && liveWindowCount() <= 1) {
4066
+ setDialogError(
4067
+ `cannot ${action} session [${id}] ${session.label} — it's the last window; open another session first`,
4068
+ );
4069
+ refreshOpenDialog();
4070
+ return;
4071
+ }
4072
+ }
4073
+ if (action === "archive") {
4074
+ const session = orchestratorSessions.get(id);
4075
+ if (session && !ownsWorktree(session)) {
4076
+ setDialogError(
4077
+ `cannot archive session [${id}] ${session.label} it has no dedicated worktree; use Delete to forget it`,
4078
+ );
4079
+ refreshOpenDialog();
4080
+ return;
3937
4081
  }
3938
4082
  }
3939
4083
  openDialog.pendingConfirm = { action, ids: [id] };
@@ -4098,7 +4242,16 @@ editor.on("widget_event", (e) => {
4098
4242
  refreshOpenDialog();
4099
4243
  return;
4100
4244
  }
4101
- if (e.event_type === "select" && e.widget_key === "sessions") {
4245
+ // List selection. Keyboard nav fires this with `widget_key`
4246
+ // "sessions" (the list's own key); a mouse click on a row fires it
4247
+ // with `widget_key` set to the clicked item's key, carrying the
4248
+ // list key in `payload.list_key` instead — accept both so clicking a
4249
+ // row selects it (highlight + preview) just like arrowing to it.
4250
+ if (
4251
+ e.event_type === "select" &&
4252
+ (e.widget_key === "sessions" ||
4253
+ ((e.payload ?? {}) as Record<string, unknown>).list_key === "sessions")
4254
+ ) {
4102
4255
  const payload = (e.payload ?? {}) as Record<string, unknown>;
4103
4256
  const idx = payload.index;
4104
4257
  if (typeof idx === "number") {
@@ -4168,6 +4321,12 @@ editor.on("widget_event", (e) => {
4168
4321
  toggleShowWorktrees();
4169
4322
  return;
4170
4323
  }
4324
+ if (e.event_type === "toggle" && e.widget_key === "hide-trivial") {
4325
+ // Same pattern as the worktree toggle: route the click through the
4326
+ // shared flip so the checkbox and the Alt+I chord stay in sync.
4327
+ toggleHideTrivial();
4328
+ return;
4329
+ }
4171
4330
  if (e.event_type === "activate" && e.widget_key === "stop") {
4172
4331
  enterConfirm("stop");
4173
4332
  return;
@@ -4258,10 +4417,6 @@ function killSelected(): void {
4258
4417
  editor.setStatus("Orchestrator: select a session row first");
4259
4418
  return;
4260
4419
  }
4261
- if (id === 1) {
4262
- editor.setStatus("Orchestrator: cannot kill the base session");
4263
- return;
4264
- }
4265
4420
  if (id === editor.activeWindow()) {
4266
4421
  editor.setStatus(
4267
4422
  "Orchestrator: dive elsewhere first, then kill this session",