@fresh-editor/fresh-editor 0.3.7 → 0.3.8

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.
@@ -84,27 +84,6 @@ interface AgentSession {
84
84
 
85
85
  const orchestratorSessions = new Map<number, AgentSession>();
86
86
 
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;
107
-
108
87
  // New-session form state. `null` ⇒ the floating form isn't
109
88
  // open. Each field's `value` + `cursor` mirrors what the host
110
89
  // renders inside the panel's TextInput widgets; the `submitting`
@@ -267,9 +246,25 @@ interface OpenDialogState {
267
246
  // too easy to skip over when the user's eyes are on the dialog.
268
247
  // Cleared on the next nav / filter change.
269
248
  lastError: string | null;
249
+ // Which sessions the list foregrounds:
250
+ // - "current": only sessions belonging to the active window's
251
+ // project (the default — launching in project B shouldn't
252
+ // bury you under project A's sessions). A trailing affordance
253
+ // row advertises how many sessions live in other projects.
254
+ // - "all": every session, across every project, each row
255
+ // labeled with its project so cross-project rows are obvious.
256
+ // Toggled with the scope key (⌥P by default). The filter input
257
+ // always searches globally regardless of scope, so typing a name
258
+ // from another project still surfaces it.
259
+ scope: "current" | "all";
270
260
  }
271
261
  let openDialog: OpenDialogState | null = null;
272
262
  let openPanel: FloatingWidgetPanel | null = null;
263
+ // Scope is remembered across opens of the picker (module state
264
+ // survives dialog close). Defaults to "all" so the picker opens
265
+ // showing every session; flipping it with the Project control / Alt+P
266
+ // updates this and the next open honours it.
267
+ let lastOpenScope: "current" | "all" = "all";
273
268
  const OPEN_MODE = "orchestrator-open";
274
269
 
275
270
  // =============================================================================
@@ -346,14 +341,76 @@ function ageString(createdAt: number): string {
346
341
  // The picker is cross-project by design — every session is a
347
342
  // candidate regardless of which project the active window
348
343
  // points at — so there is no project-scope filter here.
344
+ // Project a session belongs to, as a comparison key. Prefer the
345
+ // canonical `projectPath` recorded at create time; fall back to
346
+ // the session root for sessions that predate the field (the base
347
+ // session, externally-created windows).
348
+ function projectKeyOf(s: AgentSession): string {
349
+ return s.projectPath ?? s.root;
350
+ }
351
+
352
+ // The project the user is currently "in" — the active window's
353
+ // project. Falls back to the editor cwd when the active window
354
+ // isn't a tracked session (shouldn't normally happen, but keeps
355
+ // scoping well-defined).
356
+ function currentProjectKey(): string {
357
+ const s = orchestratorSessions.get(editor.activeWindow());
358
+ return s ? projectKeyOf(s) : editor.getCwd();
359
+ }
360
+
361
+ // Short, human-readable label for a project key — the trailing
362
+ // `parent/base` of the path, matching the new-session form's
363
+ // `deriveProjectLabel` style.
364
+ function projectLabel(key: string): string {
365
+ const base = editor.pathBasename(key);
366
+ const parent = editor.pathBasename(editor.pathDirname(key));
367
+ if (parent && parent !== base) return `${parent}/${base}`;
368
+ return base || key;
369
+ }
370
+
371
+ // Resolve the id list for the current filter + scope.
372
+ //
373
+ // Scope only constrains the *empty-filter* view: with no needle
374
+ // and `scope === "current"`, the list shows just the active
375
+ // project's sessions (current project first, by id). As soon as
376
+ // the user types, the search goes global regardless of scope —
377
+ // hiding a session the user is explicitly searching for would be
378
+ // the worse surprise. `scope === "all"` always shows everything,
379
+ // sorted by project (current project first) so rows are grouped
380
+ // rather than interleaved.
349
381
  function filterSessions(needle: string): number[] {
350
382
  reconcileSessions();
351
- const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
352
- if (!needle) return ids;
383
+ const scope = openDialog?.scope ?? "current";
384
+ const cur = currentProjectKey();
385
+ const allIds = Array.from(orchestratorSessions.keys());
386
+
387
+ // Sort by (current-project-first, then id) so an "all" view
388
+ // groups the current project's sessions at the top and other
389
+ // projects' sessions below in a stable order.
390
+ const byProjectThenId = (a: number, b: number): number => {
391
+ const sa = orchestratorSessions.get(a)!;
392
+ const sb = orchestratorSessions.get(b)!;
393
+ const aCur = projectKeyOf(sa) === cur ? 0 : 1;
394
+ const bCur = projectKeyOf(sb) === cur ? 0 : 1;
395
+ if (aCur !== bCur) return aCur - bCur;
396
+ const ka = projectKeyOf(sa);
397
+ const kb = projectKeyOf(sb);
398
+ if (ka !== kb) return ka < kb ? -1 : 1;
399
+ return a - b;
400
+ };
401
+
402
+ if (!needle) {
403
+ const ids = allIds.slice().sort(byProjectThenId);
404
+ if (scope === "current") {
405
+ return ids.filter((id) => projectKeyOf(orchestratorSessions.get(id)!) === cur);
406
+ }
407
+ return ids;
408
+ }
409
+
353
410
  const n = needle.toLowerCase();
354
411
  type Scored = { id: number; score: number; len: number };
355
412
  const matches: Scored[] = [];
356
- for (const id of ids) {
413
+ for (const id of allIds) {
357
414
  const s = orchestratorSessions.get(id)!;
358
415
  const label = s.label.toLowerCase();
359
416
  const root = s.root.toLowerCase();
@@ -369,11 +426,33 @@ function filterSessions(needle: string): number[] {
369
426
  return matches.map((m) => m.id);
370
427
  }
371
428
 
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
429
+ // Column widths for the tabular session list. ID holds `[NN] `;
430
+ // NAME holds the label plus the BASE / ⇄ badges; PROJECT (filled
431
+ // only for cross-project rows) trails. Kept in sync with
432
+ // `sessionsColumnHeader`.
433
+ const LIST_ID_W = 5;
434
+ const LIST_NAME_W = 20;
435
+
436
+ // Header row above the session list: `ID NAME … PROJECT`.
437
+ function sessionsColumnHeader(): WidgetSpec {
438
+ return {
439
+ kind: "raw",
440
+ entries: [
441
+ styledRow([
442
+ {
443
+ text: "ID".padEnd(LIST_ID_W) + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
444
+ style: { fg: "ui.menu_disabled_fg" },
445
+ },
446
+ ]),
447
+ ],
448
+ };
449
+ }
450
+
451
+ // Build one rendered list-item row for `id`, laid out in columns:
452
+ // `[id]` <name + BASE/⇄ badges> <project basename>
453
+ // The active session's id renders in the active-tab colour (the
454
+ // list has no separate state column); the project column is filled
455
+ // only for sessions that don't belong to the current project.
377
456
  function renderListItem(id: number, activeId: number): TextPropertyEntry {
378
457
  const s = orchestratorSessions.get(id);
379
458
  if (!s) {
@@ -381,37 +460,38 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
381
460
  }
382
461
  const isActive = id === activeId;
383
462
  const isBase = id === 1;
384
- const stateText = isActive ? "ACT " : STATE_GLYPH[s.state];
463
+
464
+ const idText = `[${id}]`.padEnd(LIST_ID_W);
385
465
  const entries: { text: string; style?: Record<string, unknown> }[] = [
386
- { text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
387
466
  {
388
- text: stateText,
467
+ text: idText,
389
468
  style: isActive
390
469
  ? { fg: "ui.tab_active_fg", bold: true }
391
- : { fg: "ui.menu_disabled_fg" },
470
+ : { fg: "ui.help_key_fg" },
392
471
  },
393
- { text: ` ${s.label}` },
472
+ { text: s.label, style: isActive ? { bold: true } : undefined },
394
473
  ];
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.
474
+ // Visible width of the NAME column so far (label + badges), used
475
+ // to pad out to LIST_NAME_W before the PROJECT column.
476
+ let nameWidth = s.label.length;
400
477
  if (isBase) {
401
- entries.push({
402
- text: " BASE",
403
- style: { fg: "ui.help_key_fg", bold: true },
404
- });
478
+ entries.push({ text: " BASE", style: { fg: "ui.help_key_fg", bold: true } });
479
+ nameWidth += 5;
405
480
  }
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
481
  if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
482
+ entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
483
+ nameWidth += 2;
484
+ }
485
+ // PROJECT column: basename for cross-project rows only; current-
486
+ // project rows leave it blank (the whole list is one project when
487
+ // scoped, so this column is empty then).
488
+ const proj = projectKeyOf(s);
489
+ if (proj !== currentProjectKey()) {
490
+ const pad = Math.max(1, LIST_NAME_W - nameWidth);
491
+ entries.push({ text: " ".repeat(pad) });
412
492
  entries.push({
413
- text: " ⇄",
414
- style: { fg: "ui.menu_disabled_fg" },
493
+ text: editor.pathBasename(proj),
494
+ style: { fg: "ui.menu_disabled_fg", italic: true },
415
495
  });
416
496
  }
417
497
  return styledRow(entries as Parameters<typeof styledRow>[0]);
@@ -514,20 +594,26 @@ function sessionsSeparator(): WidgetSpec {
514
594
  return spacer(0);
515
595
  }
516
596
 
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 {
597
+ // Smallest list height we'll show even when there are only a
598
+ // couple of sessions keeps the preview pane (which matches the
599
+ // list height) usable rather than collapsing to a sliver.
600
+ const MIN_LIST_ROWS = 6;
601
+
602
+ // Upper bound on session rows for this terminal — the list height
603
+ // when the panel is at its full `heightPct: 90` budget. Sized off
604
+ // the full terminal (not the active buffer's viewport — that
605
+ // shrinks with vertical splits and made the picker collapse to
606
+ // ~half its budget).
607
+ function maxListRowsForScreen(): number {
522
608
  const screen = editor.getScreenSize();
523
609
  const h = screen.height > 0 ? screen.height : 30;
524
610
  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);
611
+ // Chrome that isn't list rows: panel borders (2) + title (1) +
612
+ // spacer (1) + footer (1) + sessions-section borders (2) +
613
+ // column chrome above the list (New + Project + Filter +
614
+ // separator + header = 5) = 12. Floor at MIN_LIST_ROWS so a tiny
615
+ // terminal still shows something.
616
+ return Math.max(MIN_LIST_ROWS, panelH - 12);
531
617
  }
532
618
 
533
619
  // Compose the right-hand preview pane. Normally it shows info
@@ -688,14 +774,14 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
688
774
  }
689
775
  }
690
776
  // 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;
777
+ // bottom borders land on the same row. Sessions column inside its
778
+ // borders = New (1) + Project (1) + Filter (1) + separator (1) +
779
+ // header (1) + list (listVisibleRows) = listVisibleRows + 5.
780
+ // Preview inside its borders = button row (1) + spacer (1) +
781
+ // embedRows, so embedRows must equal listVisibleRows + 3. When
782
+ // details ARE shown, two info rows + a spacer eat three more
783
+ // lines — `_DETAILS_CHROME_ROWS` accounts for that.
784
+ const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 3;
699
785
  const detailsOn = openDialog?.showDetails ?? false;
700
786
  const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
701
787
  const embedRows = Math.max(
@@ -793,24 +879,11 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
793
879
 
794
880
  function buildOpenSpec(): WidgetSpec {
795
881
  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
882
  const filtered = openDialog.filteredIds;
883
+ // Fill the panel's full height budget (the list pads with blank
884
+ // rows when there are few sessions) so the dialog stays
885
+ // vertically full rather than collapsing to a short floating box.
886
+ openDialog.listVisibleRows = maxListRowsForScreen();
814
887
  const activeId = editor.activeWindow();
815
888
  const items = filtered.map((id) => renderListItem(id, activeId));
816
889
  const itemKeys = filtered.map(String);
@@ -838,9 +911,7 @@ function buildOpenSpec(): WidgetSpec {
838
911
  "orchestrator_open_new_from_picker",
839
912
  OPEN_MODE,
840
913
  );
841
- const newLabel = newKey
842
- ? `+ New Session ${newKey}`
843
- : "+ New Session";
914
+ const newLabel = newKey ? `+ New ${newKey}` : "+ New";
844
915
  const inConfirm = openDialog.pendingConfirm !== null;
845
916
  // While a confirmation prompt is up the filter is rendered
846
917
  // without a `key`. The host's `collect_tabbable` only adds
@@ -853,7 +924,8 @@ function buildOpenSpec(): WidgetSpec {
853
924
  const filterInput = text({
854
925
  value: openDialog.filter.value,
855
926
  cursorByte: openDialog.filter.cursor,
856
- placeholder: "type to filter…",
927
+ label: "Filter",
928
+ placeholder: "type to search… ( / )",
857
929
  fullWidth: true,
858
930
  key: inConfirm ? undefined : "filter",
859
931
  });
@@ -874,6 +946,35 @@ function buildOpenSpec(): WidgetSpec {
874
946
  ],
875
947
  }
876
948
  : null;
949
+
950
+ // Scope chrome. The title keeps the active project visible; the
951
+ // `Project:` control below is the clickable scope switch.
952
+ const scope = openDialog.scope;
953
+ const curKey = currentProjectKey();
954
+ const curName = projectLabel(curKey);
955
+ const scopeKey = editor.getKeybindingLabel("orchestrator_toggle_scope", OPEN_MODE);
956
+ const titleSuffix = scope === "current" ? ` — ${curName}` : " — all projects";
957
+ const sectionLabel = "Sessions";
958
+ // `Project:` control — a visible, clickable scope switch with the
959
+ // Alt+P hint baked into the button label. Shows the current
960
+ // project's name when scoped, "All" when showing every project.
961
+ // Inert while a confirm prompt is up so it can't steal focus.
962
+ const scopeWord = scope === "current" ? editor.pathBasename(curKey) : "All";
963
+ const scopeButtonLabel = scopeKey ? `${scopeWord} ▾ (${scopeKey})` : `${scopeWord} ▾`;
964
+ const scopeButton = button(scopeButtonLabel, {
965
+ key: openDialog.pendingConfirm !== null ? undefined : "scope-toggle",
966
+ });
967
+ const projectControlRow = row(
968
+ {
969
+ kind: "raw",
970
+ entries: [
971
+ styledRow([{ text: "Project: ", style: { fg: "ui.menu_disabled_fg" } }]),
972
+ ],
973
+ },
974
+ scopeButton,
975
+ flexSpacer(),
976
+ );
977
+
877
978
  return col(
878
979
  {
879
980
  kind: "raw",
@@ -883,6 +984,10 @@ function buildOpenSpec(): WidgetSpec {
883
984
  text: "ORCHESTRATOR :: Sessions",
884
985
  style: { fg: "ui.popup_border_fg", bold: true },
885
986
  },
987
+ {
988
+ text: titleSuffix,
989
+ style: { fg: "ui.menu_disabled_fg" },
990
+ },
886
991
  ]),
887
992
  ],
888
993
  },
@@ -896,14 +1001,18 @@ function buildOpenSpec(): WidgetSpec {
896
1001
  // the dialog.
897
1002
  row(
898
1003
  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.
1004
+ label: sectionLabel,
1005
+ // 34% (was 25%): wide enough that the per-row project tag in
1006
+ // the all-projects view (`· <project>`) and longer session
1007
+ // labels render without truncating to tmp_o…`. The preview
1008
+ // pane still keeps the majority for the live window embed.
1009
+ widthPct: 34,
1010
+ // Sessions column: New button, Project (scope) control,
1011
+ // Filter, separator, column header, list. The button is
1012
+ // first so it gets initial focus (Enter immediately opens the
1013
+ // new session form). Separators are long `─` strings that the
1014
+ // renderer truncates to the column's inner width — no need to
1015
+ // measure cells from the plugin side.
907
1016
  child: col(
908
1017
  row(
909
1018
  button(newLabel, {
@@ -918,13 +1027,19 @@ function buildOpenSpec(): WidgetSpec {
918
1027
  }),
919
1028
  flexSpacer(),
920
1029
  ),
921
- sessionsSeparator(),
1030
+ projectControlRow,
922
1031
  filterInput,
923
1032
  sessionsSeparator(),
1033
+ sessionsColumnHeader(),
924
1034
  list({
925
1035
  items,
926
1036
  itemKeys,
927
1037
  selectedIndex: selIdx,
1038
+ // `listVisibleRows` is the fitted list height; the 5 rows
1039
+ // of column chrome above it (New / Project / Filter /
1040
+ // separator / header) and the matching preview embed are
1041
+ // accounted for separately so both panes stay the same
1042
+ // height and the footer hint stays on-screen.
928
1043
  visibleRows: openDialog.listVisibleRows,
929
1044
  // Excluded from the Tab cycle — Up/Down on the
930
1045
  // filter input forwards to this list via host
@@ -944,8 +1059,7 @@ function buildOpenSpec(): WidgetSpec {
944
1059
  ),
945
1060
  }),
946
1061
  // Preview pane has no explicit width — picks up the
947
- // remaining 75% by default since the sessions list took
948
- // 25%.
1062
+ // remaining width by default since the sessions list took 34%.
949
1063
  buildPreviewPane(selectedSession),
950
1064
  ),
951
1065
  row(
@@ -953,6 +1067,10 @@ function buildOpenSpec(): WidgetSpec {
953
1067
  hintBar([
954
1068
  { keys: "↑↓", label: "nav" },
955
1069
  { keys: "Enter", label: "dive" },
1070
+ {
1071
+ keys: scopeKey || "⌥P",
1072
+ label: scope === "current" ? "all projects" : "current only",
1073
+ },
956
1074
  { keys: "Tab", label: "focus" },
957
1075
  { keys: "Esc", label: "close" },
958
1076
  ]),
@@ -1032,7 +1150,9 @@ function openControlRoom(): void {
1032
1150
  if (openPanel) return;
1033
1151
  reconcileSessions();
1034
1152
  const activeId = editor.activeWindow();
1035
- const listVisibleRows = openListVisibleRows();
1153
+ // Seed with the screen-max; buildOpenSpec refits to the session
1154
+ // count on the first render (and every render after).
1155
+ const listVisibleRows = maxListRowsForScreen();
1036
1156
  openDialog = {
1037
1157
  filter: { value: "", cursor: 0 },
1038
1158
  filteredIds: [],
@@ -1040,19 +1160,13 @@ function openControlRoom(): void {
1040
1160
  originalActiveSession: activeId,
1041
1161
  pendingConfirm: null,
1042
1162
  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),
1163
+ embedRows: Math.max(3, listVisibleRows + 3),
1053
1164
  showDetails: false,
1054
1165
  inFlight: null,
1055
1166
  lastError: null,
1167
+ // Restore the last-used scope (defaults to "all"); the Project
1168
+ // control / Alt+P updates it for next time.
1169
+ scope: lastOpenScope,
1056
1170
  };
1057
1171
  openDialog.filteredIds = filterSessions("");
1058
1172
  const activeIdx = openDialog.filteredIds.indexOf(activeId);
@@ -1616,7 +1730,19 @@ async function deleteConfirmedSession(): Promise<void> {
1616
1730
  // since OPEN_MODE doesn't claim them here.
1617
1731
  editor.defineMode(
1618
1732
  OPEN_MODE,
1619
- [["M-n", "orchestrator_open_new_from_picker"]],
1733
+ [
1734
+ ["M-n", "orchestrator_open_new_from_picker"],
1735
+ // Scope toggle: flip the list between "current project only"
1736
+ // and "all projects". Registered as a mode chord so it's
1737
+ // user-rebindable and renders cross-platform (⌥P / Alt+P).
1738
+ ["M-p", "orchestrator_toggle_scope"],
1739
+ // `/` jumps focus to the filter input — the familiar
1740
+ // search-focus shortcut. (As a mode chord it's intercepted even
1741
+ // while the filter has focus, so `/` can't be typed as filter
1742
+ // text; session names don't contain `/`, so that's an
1743
+ // acceptable trade for the quick-focus.)
1744
+ ["/", "orchestrator_focus_filter"],
1745
+ ],
1620
1746
  true,
1621
1747
  true,
1622
1748
  );
@@ -1627,6 +1753,29 @@ registerHandler("orchestrator_open_new_from_picker", () => {
1627
1753
  openForm({ fromPicker: true });
1628
1754
  });
1629
1755
 
1756
+ registerHandler("orchestrator_focus_filter", () => {
1757
+ if (!openDialog || !openPanel) return;
1758
+ openPanel.setFocusKey("filter");
1759
+ });
1760
+
1761
+ function toggleScope(): void {
1762
+ if (!openDialog) return;
1763
+ openDialog.scope = openDialog.scope === "current" ? "all" : "current";
1764
+ // Remember the choice for the next time the picker opens.
1765
+ lastOpenScope = openDialog.scope;
1766
+ // Keep the highlighted session selected across the scope flip
1767
+ // when it survives into the new list; otherwise fall back to the
1768
+ // top. The filter value is untouched — toggling scope with an
1769
+ // active filter just widens/narrows the global-search base.
1770
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
1771
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
1772
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
1773
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
1774
+ refreshOpenDialog();
1775
+ }
1776
+
1777
+ registerHandler("orchestrator_toggle_scope", toggleScope);
1778
+
1630
1779
  // =============================================================================
1631
1780
  // New-session floating form
1632
1781
  // =============================================================================
@@ -2210,7 +2359,6 @@ function renderForm(): void {
2210
2359
  }
2211
2360
 
2212
2361
  function openForm(options?: { fromPicker?: boolean }): void {
2213
- pendingNewSession = null;
2214
2362
  const lastCmd =
2215
2363
  (editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
2216
2364
  form = {
@@ -2713,11 +2861,11 @@ async function submitForm(): Promise<void> {
2713
2861
  editor.setGlobalState("orchestrator.last_cmd", cmd);
2714
2862
  }
2715
2863
 
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.
2864
+ // Branch / cmd values used for the per-window state record
2865
+ // `branchName` only exists in the worktree-create flow above;
2866
+ // for the shared-worktree / non-git case we report whatever's
2867
+ // currently checked out (best-effort) so the new session record
2868
+ // matches the situation on disk.
2721
2869
  const reportedBranch = createWorktree
2722
2870
  ? (branchInput || sessionName)
2723
2871
  : "";
@@ -2729,16 +2877,47 @@ async function submitForm(): Promise<void> {
2729
2877
  if (cmd) appendHistory("cmd", cmd);
2730
2878
  if (createWorktree) appendHistory("branch", reportedBranch);
2731
2879
 
2732
- pendingNewSession = {
2733
- label: sessionName,
2734
- branch: reportedBranch,
2735
- cmd,
2736
- root,
2737
- projectPath,
2738
- sharedWorktree: !createWorktree,
2739
- };
2740
2880
  closeForm();
2741
- editor.createWindow(root, sessionName);
2881
+
2882
+ // Spawn the new window + agent terminal atomically. Compared to
2883
+ // the legacy `createWindow → window_created hook → createTerminal`
2884
+ // chain this avoids the transient `[No Name]` tab the host's
2885
+ // eager seed used to leave alongside the agent terminal: the
2886
+ // terminal IS the new window's seed buffer, so the window is
2887
+ // born with a single tab.
2888
+ const argv = splitAgentCmd(cmd);
2889
+ const sharedWorktree = !createWorktree;
2890
+ try {
2891
+ const result = await editor.createWindowWithTerminal({
2892
+ root,
2893
+ label: sessionName,
2894
+ cwd: root,
2895
+ command: argv.length > 0 ? argv : undefined,
2896
+ title: argv.length > 0 ? argv[0] : undefined,
2897
+ });
2898
+ const id = result.windowId;
2899
+ // `createWindowWithTerminal` already dove into the new window,
2900
+ // so `setWindowState` writes to it.
2901
+ editor.setWindowState("project_path", projectPath);
2902
+ editor.setWindowState("shared_worktree", sharedWorktree);
2903
+ const tracked: AgentSession = {
2904
+ id,
2905
+ label: sessionName,
2906
+ root,
2907
+ projectPath,
2908
+ sharedWorktree,
2909
+ terminalId: result.terminalId,
2910
+ state: "running",
2911
+ createdAt: Date.now(),
2912
+ };
2913
+ orchestratorSessions.set(id, tracked);
2914
+ } catch (e) {
2915
+ editor.setStatus(
2916
+ `Orchestrator: failed to start session — ${
2917
+ e instanceof Error ? e.message : String(e)
2918
+ }`,
2919
+ );
2920
+ }
2742
2921
  }
2743
2922
 
2744
2923
  function startNewSession(): void {
@@ -3149,6 +3328,10 @@ editor.on("widget_event", (e) => {
3149
3328
  openForm({ fromPicker: true });
3150
3329
  return;
3151
3330
  }
3331
+ if (e.event_type === "activate" && e.widget_key === "scope-toggle") {
3332
+ toggleScope();
3333
+ return;
3334
+ }
3152
3335
  if (e.event_type === "activate" && e.widget_key === "toggle-details") {
3153
3336
  openDialog.showDetails = !openDialog.showDetails;
3154
3337
  refreshOpenDialog();
@@ -3262,57 +3445,13 @@ function killSelected(): void {
3262
3445
  // Lifecycle hook handlers
3263
3446
  // =============================================================================
3264
3447
 
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
- }
3448
+ editor.on("window_created", () => {
3449
+ // The orchestrator's own new-session flow uses
3450
+ // `createWindowWithTerminal` (atomic — populates the window
3451
+ // before returning), so by the time this hook fires for one of
3452
+ // our spawns the session is already tracked. Other plugins or
3453
+ // host actions creating windows just need the picker to
3454
+ // refresh.
3316
3455
  refreshOpenDialog();
3317
3456
  });
3318
3457
 
@@ -3336,9 +3475,8 @@ editor.on("active_window_changed", () => {
3336
3475
  // viewport at the same time.
3337
3476
  editor.on("resize", () => {
3338
3477
  if (openDialog && openPanel) {
3339
- const listVisibleRows = openListVisibleRows();
3340
- openDialog.listVisibleRows = listVisibleRows;
3341
- openDialog.embedRows = Math.max(3, listVisibleRows - 5);
3478
+ // buildOpenSpec refits `listVisibleRows` to the session count
3479
+ // (bounded by the new screen budget) on the refresh below.
3342
3480
  refreshOpenDialog();
3343
3481
  }
3344
3482
  });