@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.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/plugins/audit_mode.ts +35 -1
- package/plugins/config-schema.json +9 -3
- package/plugins/diagnostics_panel.ts +10 -0
- package/plugins/env-manager.i18n.json +338 -0
- package/plugins/env-manager.ts +23 -33
- package/plugins/lib/finder.ts +44 -7
- package/plugins/lib/fresh.d.ts +55 -37
- package/plugins/live_diff.ts +12 -1
- package/plugins/live_grep.ts +66 -2
- package/plugins/markdown_compose.ts +3 -1
- package/plugins/orchestrator.ts +327 -172
- package/plugins/schemas/theme.schema.json +15 -2
- package/plugins/search_replace.ts +70 -28
- package/plugins/theme_editor.i18n.json +28 -0
package/plugins/orchestrator.ts
CHANGED
|
@@ -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 +
|
|
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 +
|
|
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 (
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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 =
|
|
903
|
-
// a tiny terminal still shows something.
|
|
904
|
-
return Math.max(MIN_LIST_ROWS, panelH -
|
|
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
|
-
//
|
|
965
|
-
// = listVisibleRows +
|
|
966
|
-
// row (1) + spacer (1) + embedRows, so embedRows
|
|
967
|
-
// listVisibleRows +
|
|
968
|
-
// spacer eat three more lines — `_DETAILS_CHROME_ROWS`
|
|
969
|
-
// for that.
|
|
970
|
-
const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) +
|
|
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:
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
const
|
|
1061
|
-
const
|
|
1062
|
-
const stopDisabled =
|
|
1063
|
-
const
|
|
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:
|
|
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:
|
|
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
|
|
1095
|
-
// (the list-row badge gets truncated at 25% column width).
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
|
|
1099
|
-
|
|
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 (
|
|
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: " ·
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
|
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
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
"git"
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
-
//
|
|
3908
|
-
//
|
|
3909
|
-
//
|
|
3910
|
-
//
|
|
3911
|
-
//
|
|
3912
|
-
//
|
|
3913
|
-
//
|
|
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
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
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
|
-
|
|
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",
|